Sistema de Notificaciones y Baja One-Click

This commit is contained in:
2026-03-12 13:52:33 -03:00
parent f1a9bb9099
commit 96fca4d9c7
21 changed files with 1384 additions and 79 deletions

View File

@@ -38,6 +38,7 @@ public class AdExpirationService : BackgroundService
await PermanentDeleteOldDeletedAdsAsync();
await CleanupOldRefreshTokensAsync();
await CleanupAdViewLogsAsync();
await CleanupUnsubscribeTokensAsync();
// 3. Marketing y Retención
await ProcessWeeklyStatsAsync();
@@ -80,12 +81,36 @@ public class AdExpirationService : BackgroundService
}
}
private async Task CheckExpiredAdsAsync()
private async Task CleanupUnsubscribeTokensAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
// Borramos tokens que ya expiraron o que ya fueron usados
var now = DateTime.UtcNow;
var deletedCount = await context.UnsubscribeTokens
.Where(t => t.ExpiresAt <= now || t.IsUsed)
.ExecuteDeleteAsync();
if (deletedCount > 0)
{
logger.LogInformation("Mantenimiento: Se eliminaron {Count} tokens de baja expirados o usados.", deletedCount);
}
}
}
private async Task CheckExpiredAdsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var cutoffDate = DateTime.UtcNow.AddDays(-30);
@@ -106,10 +131,17 @@ public class AdExpirationService : BackgroundService
{
ad.StatusID = (int)AdStatusEnum.Expired;
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email))
// Solo enviamos el correo si el usuario tiene habilitada la categoría "sistema"
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email)
&& await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Sistema))
{
var title = $"{ad.Brand?.Name} {ad.VersionName}";
await notifService.SendAdExpiredEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title);
var title = $"{ad.Brand?.Name} {ad.VersionName}";
// Generamos el token de baja para la categoría "sistema"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendAdExpiredEmailAsync(
ad.User.Email, ad.User.FirstName ?? "Usuario", title, unsubscribeUrl);
}
context.AuditLogs.Add(new AuditLog
@@ -129,8 +161,11 @@ public class AdExpirationService : BackgroundService
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var warningThreshold = DateTime.UtcNow.AddDays(-25);
@@ -150,12 +185,22 @@ public class AdExpirationService : BackgroundService
{
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
var title = $"{ad.Brand?.Name} {ad.VersionName}";
var title = $"{ad.Brand?.Name} {ad.VersionName}";
var expDate = ad.PublishedAt!.Value.AddDays(30);
try
{
await notifService.SendExpirationWarningEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title, expDate);
// Respetamos la preferencia de la categoría "sistema"
if (await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Sistema))
{
// Generamos el token de baja para la categoría "sistema"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendExpirationWarningEmailAsync(
ad.User.Email, ad.User.FirstName ?? "Usuario", title, expDate, unsubscribeUrl);
}
// La bandera se marca igual para no reintentar aunque el usuario no quiera el email
ad.ExpirationWarningSent = true;
}
catch { /* Log error pero continuar */ }
@@ -169,8 +214,11 @@ public class AdExpirationService : BackgroundService
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
@@ -190,15 +238,28 @@ public class AdExpirationService : BackgroundService
{
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
// Respetamos la preferencia de la categoría "rendimiento"
if (!await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Rendimiento))
{
// Actualizamos la fecha para no consultar este aviso en el próximo ciclo
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
continue;
}
var favCount = await context.Favorites.CountAsync(f => f.AdID == ad.AdID);
var title = $"{ad.Brand?.Name} {ad.VersionName}";
var title = $"{ad.Brand?.Name} {ad.VersionName}";
// Generamos el token de baja para la categoría "rendimiento"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Rendimiento);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendWeeklyPerformanceEmailAsync(
ad.User.Email,
ad.User.FirstName ?? "Usuario",
title,
ad.ViewsCounter,
favCount
favCount,
unsubscribeUrl
);
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
@@ -212,11 +273,11 @@ public class AdExpirationService : BackgroundService
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var cutoff = DateTime.UtcNow.AddHours(-24);
@@ -236,10 +297,19 @@ public class AdExpirationService : BackgroundService
{
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
var title = $"{ad.Brand?.Name} {ad.VersionName}";
var link = $"{frontendUrl}/publicar?edit={ad.AdID}";
// Respetamos la preferencia de la categoría "marketing" (carrito abandonado)
if (await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Marketing))
{
var title = $"{ad.Brand?.Name} {ad.VersionName}";
var link = $"{frontendUrl}/publicar?edit={ad.AdID}";
await notifService.SendPaymentReminderEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title, link);
// Generamos el token de baja para la categoría "marketing"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Marketing);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendPaymentReminderEmailAsync(
ad.User.Email, ad.User.FirstName ?? "Usuario", title, link, unsubscribeUrl);
}
ad.PaymentReminderSentAt = DateTime.UtcNow;
}
@@ -252,10 +322,13 @@ public class AdExpirationService : BackgroundService
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
// Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas)
// Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas)
// y que no hayan sido notificados en las últimas 24 horas.
var messageThreshold = DateTime.UtcNow.AddHours(-4);
@@ -282,7 +355,16 @@ public class AdExpirationService : BackgroundService
// Contar total no leídos
var totalUnread = await context.ChatMessages.CountAsync(m => m.ReceiverID == userId && !m.IsRead);
await notifService.SendUnreadMessagesReminderEmailAsync(user.Email, user.FirstName ?? "Usuario", totalUnread);
// Respetamos la preferencia de la categoría "mensajes"
if (await prefService.IsEnabledAsync(userId, NotificationCategory.Mensajes))
{
// Generamos el token de baja para la categoría "mensajes"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(userId, NotificationCategory.Mensajes);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendUnreadMessagesReminderEmailAsync(
user.Email, user.FirstName ?? "Usuario", totalUnread, unsubscribeUrl);
}
user.LastUnreadMessageReminderSentAt = DateTime.UtcNow;
}

View File

@@ -0,0 +1,278 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class NotificationPreferenceService : INotificationPreferenceService
{
private readonly MotoresV2DbContext _context;
private readonly IConfiguration _config;
private readonly ILogger<NotificationPreferenceService> _logger;
// Clave secreta para firmar los tokens de baja (HMAC-SHA256)
private readonly string _hmacSecret;
public NotificationPreferenceService(
MotoresV2DbContext context,
IConfiguration config,
ILogger<NotificationPreferenceService> logger)
{
_context = context;
_config = config;
_logger = logger;
// La clave se lee de la configuración; si no existe, fallback a la clave JWT (nunca null en producción)
_hmacSecret = config["Unsubscribe:HmacSecret"]
?? config["Jwt:Key"]
?? throw new InvalidOperationException("Falta clave HMAC para tokens de baja.");
}
/// <inheritdoc/>
public async Task<NotificationPreferencesDto> GetPreferencesAsync(int userId)
{
// Cargamos todas las preferencias guardadas del usuario
var prefs = await _context.NotificationPreferences
.Where(p => p.UserID == userId)
.ToListAsync();
// Si no hay registro, la preferencia es TRUE (habilitada) por defecto
return new NotificationPreferencesDto
{
Sistema = GetIsEnabled(prefs, NotificationCategory.Sistema),
Marketing = GetIsEnabled(prefs, NotificationCategory.Marketing),
Rendimiento = GetIsEnabled(prefs, NotificationCategory.Rendimiento),
Mensajes = GetIsEnabled(prefs, NotificationCategory.Mensajes),
};
}
private static bool GetIsEnabled(List<UserNotificationPreference> prefs, string category)
{
var pref = prefs.FirstOrDefault(p => p.Category == category);
// Sin registro = habilitado por defecto
return pref?.IsEnabled ?? true;
}
/// <inheritdoc/>
public async Task UpdatePreferencesAsync(int userId, UpdateNotificationPreferencesDto dto)
{
// Mapa categoría → valor del DTO
var updates = new Dictionary<string, bool>
{
[NotificationCategory.Sistema] = dto.Sistema,
[NotificationCategory.Marketing] = dto.Marketing,
[NotificationCategory.Rendimiento] = dto.Rendimiento,
[NotificationCategory.Mensajes] = dto.Mensajes,
};
foreach (var (category, isEnabled) in updates)
{
var existing = await _context.NotificationPreferences
.FirstOrDefaultAsync(p => p.UserID == userId && p.Category == category);
if (existing == null)
{
// Solo creamos el registro si el usuario está DESACTIVANDO (optimización)
// Si es true y no hay registro, es el valor por defecto → no necesitamos guardar nada
if (!isEnabled)
{
_context.NotificationPreferences.Add(new UserNotificationPreference
{
UserID = userId,
Category = category,
IsEnabled = false,
UpdatedAt = DateTime.UtcNow
});
}
}
else
{
existing.IsEnabled = isEnabled;
existing.UpdatedAt = DateTime.UtcNow;
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Preferencias de notificación actualizadas para UserID {UserId}", userId);
}
/// <inheritdoc/>
public async Task<bool> IsEnabledAsync(int userId, string category)
{
var pref = await _context.NotificationPreferences
.FirstOrDefaultAsync(p => p.UserID == userId && p.Category == category);
// Sin registro = habilitado por defecto
return pref?.IsEnabled ?? true;
}
/// <inheritdoc/>
public async Task<string> GetOrCreateUnsubscribeTokenAsync(int userId, string category)
{
// Reutilizamos un token existente y vigente si lo hay
var existing = await _context.UnsubscribeTokens
.FirstOrDefaultAsync(t =>
t.UserID == userId &&
t.Category == category &&
!t.IsUsed &&
t.ExpiresAt > DateTime.UtcNow);
if (existing != null)
return existing.Token;
// Generamos un nuevo token seguro: GUID aleatorio + firma HMAC
var rawToken = GenerateSignedToken(userId, category);
_context.UnsubscribeTokens.Add(new UnsubscribeToken
{
UserID = userId,
Category = category,
Token = rawToken,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(365),
IsUsed = false
});
await _context.SaveChangesAsync();
return rawToken;
}
/// <inheritdoc/>
public async Task<(bool Success, string CategoryLabel)> UnsubscribeAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
return (false, string.Empty);
// Buscamos el token en la base de datos
var record = await _context.UnsubscribeTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == token);
if (record == null)
{
_logger.LogWarning("Intento de baja con token inexistente.");
return (false, string.Empty);
}
if (record.IsUsed)
{
// El token ya fue usado previamente; la baja ya está aplicada
return (true, GetCategoryLabel(record.Category));
}
if (record.ExpiresAt < DateTime.UtcNow)
{
_logger.LogWarning("Intento de baja con token expirado. UserID={UserId}", record.UserID);
return (false, string.Empty);
}
// Verificamos la firma del token para garantizar integridad
if (!VerifySignedToken(record.Token, record.UserID, record.Category))
{
_logger.LogWarning("Token de baja con firma inválida. UserID={UserId}", record.UserID);
return (false, string.Empty);
}
// Actualizamos la preferencia → deshabilitar categoría
var pref = await _context.NotificationPreferences
.FirstOrDefaultAsync(p => p.UserID == record.UserID && p.Category == record.Category);
if (pref == null)
{
_context.NotificationPreferences.Add(new UserNotificationPreference
{
UserID = record.UserID,
Category = record.Category,
IsEnabled = false,
UpdatedAt = DateTime.UtcNow
});
}
else
{
pref.IsEnabled = false;
pref.UpdatedAt = DateTime.UtcNow;
}
// Marcamos el token como usado (one-click = un solo uso)
record.IsUsed = true;
await _context.SaveChangesAsync();
_logger.LogInformation(
"Baja procesada correctamente. UserID={UserId}, Category={Category}",
record.UserID, record.Category);
return (true, GetCategoryLabel(record.Category));
}
// ─── Métodos privados de seguridad ────────────────────────────────────────
/// <summary>
/// Genera un token opaco: GUID aleatorio codificado en base64url + separador + HMAC-SHA256 del payload.
/// Formato: {guid_b64url}.{hmac_b64url}
/// </summary>
private string GenerateSignedToken(int userId, string category)
{
// Parte aleatoria para garantizar unicidad
var randomPart = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
// Payload a firmar
var payload = $"{userId}:{category}:{randomPart}";
var signature = ComputeHmac(payload);
return $"{randomPart}.{signature}";
}
/// <summary>
/// Verifica que el token almacenado corresponde realmente a userId + category
/// recalculando el HMAC y comparando de forma segura.
/// NOTA: el token en la DB ya nos garantiza la asociación UserID/Category;
/// aquí adicionalmente verificamos que nadie manipuló la columna.
/// </summary>
private bool VerifySignedToken(string token, int userId, string category)
{
// El token tiene formato: {randomPart}.{signature}
var dot = token.LastIndexOf('.');
if (dot < 0) return false;
var randomPart = token[..dot];
var storedSig = token[(dot + 1)..];
var payload = $"{userId}:{category}:{randomPart}";
var expected = ComputeHmac(payload);
// CryptographicOperations.FixedTimeEquals protege contra timing attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(storedSig),
Encoding.UTF8.GetBytes(expected));
}
private string ComputeHmac(string payload)
{
var keyBytes = Encoding.UTF8.GetBytes(_hmacSecret);
var dataBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hash = hmac.ComputeHash(dataBytes);
return Convert.ToBase64String(hash)
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
private static string GetCategoryLabel(string category) => category switch
{
NotificationCategory.Sistema => "Avisos del Sistema",
NotificationCategory.Marketing => "Promociones y Marketing",
NotificationCategory.Rendimiento => "Resumen de Rendimiento",
NotificationCategory.Mensajes => "Recordatorio de Mensajes",
_ => category
};
}

View File

@@ -18,8 +18,28 @@ public class NotificationService : INotificationService
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
}
private string GetEmailShell(string title, string content)
// ─── Shell del correo ──────────────────────────────────────────────────────
/// <summary>
/// Genera el HTML completo del correo con header, contenido y footer.
/// Si se provee unsubscribeUrl, agrega el enlace de baja en el footer.
/// </summary>
private string GetEmailShell(string title, string content, string? unsubscribeUrl = null)
{
// Footer de baja: solo se muestra si el correo tiene categoría de preferencia
var unsubscribeBlock = string.IsNullOrEmpty(unsubscribeUrl)
? string.Empty
: $@"
<div style='margin-top: 18px; padding-top: 18px; border-top: 1px solid #1f2937;'>
<p style='color: #4b5563; font-size: 11px; margin: 0; text-align: center;'>
¿No querés recibir más este tipo de correos?
<a href='{unsubscribeUrl}'
style='color: #6b7280; text-decoration: underline; font-size: 11px;'>
Darte de baja
</a>
</p>
</div>";
return $@"
<div style='background-color: #0a0c10; color: #e5e7eb; font-family: sans-serif; padding: 40px; line-height: 1.6;'>
<div style='max-width: 600px; margin: 0 auto; background-color: #12141a; border: 1px solid #1f2937; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);'>
@@ -34,12 +54,20 @@ public class NotificationService : INotificationService
</div>
<div style='padding: 20px; border-top: 1px solid #1f2937; text-align: center; background-color: #0d0f14;'>
<p style='color: #4b5563; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin: 0;'>Motores Argentinos - La Plata, Buenos Aires, Argentina</p>
{unsubscribeBlock}
</div>
</div>
</div>";
}
public async Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId)
// ─── Implementaciones ──────────────────────────────────────────────────────
/// <summary>
/// Notificación de nuevo mensaje de chat. Categoría: mensajes.
/// </summary>
public async Task SendChatNotificationEmailAsync(
string toEmail, string fromUser, string message, int adId,
string? unsubscribeUrl = null)
{
string subject = "Tienes un nuevo mensaje - Motores Argentinos";
string content = $@"
@@ -53,10 +81,15 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>VER MENSAJES</a>
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Nuevo Mensaje", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Nuevo Mensaje", content, unsubscribeUrl));
}
public async Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null)
/// <summary>
/// Aviso de cambio de estado del aviso. Categoría: sistema.
/// </summary>
public async Task SendAdStatusChangedEmailAsync(
string toEmail, string adTitle, string status, string? reason = null,
string? unsubscribeUrl = null)
{
string subject = "Estado de tu aviso - Motores Argentinos";
string color = status.ToUpper() == "APROBADO" ? "#10b981" : "#ef4444";
@@ -70,9 +103,12 @@ public class NotificationService : INotificationService
{(string.IsNullOrEmpty(reason) ? "" : $"<p style='background: #1f2937; padding: 15px; border-radius: 8px; border-left: 4px solid #ef4444;'><strong>Motivo:</strong> {reason}</p>")}
<p style='margin-top: 20px;'>Gracias por confiar en nosotros.</p>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Cambio de Estado", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Cambio de Estado", content, unsubscribeUrl));
}
/// <summary>
/// Alerta de seguridad crítica. SIN enlace de baja (siempre se envía).
/// </summary>
public async Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription)
{
string subject = "Alerta de Seguridad - Motores Argentinos";
@@ -85,10 +121,16 @@ public class NotificationService : INotificationService
<p>Si no fuiste tú, te recomendamos cambiar tu contraseña inmediatamente y contactar a nuestro equipo de soporte.</p>
<p style='margin-top: 20px;'>Atentamente,<br>Equipo de Seguridad.</p>";
// Sin unsubscribeUrl: los correos de seguridad no tienen baja
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Alerta de Seguridad", content));
}
public async Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate)
/// <summary>
/// Aviso de próximo vencimiento. Categoría: sistema.
/// </summary>
public async Task SendExpirationWarningEmailAsync(
string toEmail, string userName, string adTitle, DateTime expirationDate,
string? unsubscribeUrl = null)
{
string subject = "Tu aviso está por vencer - Motores Argentinos";
string content = $@"
@@ -99,10 +141,15 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #f59e0b; color: #000; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>RENOVAR AVISO</a>
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso por Vencer", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso por Vencer", content, unsubscribeUrl));
}
public async Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle)
/// <summary>
/// Aviso de vencimiento consumado. Categoría: sistema.
/// </summary>
public async Task SendAdExpiredEmailAsync(
string toEmail, string userName, string adTitle,
string? unsubscribeUrl = null)
{
string subject = "Tu aviso ha finalizado - Motores Argentinos";
string content = $@"
@@ -113,10 +160,15 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>REPUBLICAR AHORA</a>
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso Finalizado", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso Finalizado", content, unsubscribeUrl));
}
public async Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites)
/// <summary>
/// Resumen semanal de rendimiento del aviso. Categoría: rendimiento.
/// </summary>
public async Task SendWeeklyPerformanceEmailAsync(
string toEmail, string userName, string adTitle, int views, int favorites,
string? unsubscribeUrl = null)
{
string subject = "Resumen semanal de tu aviso - Motores Argentinos";
@@ -158,10 +210,15 @@ public class NotificationService : INotificationService
<![endif]-->
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Rendimiento Semanal", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Rendimiento Semanal", content, unsubscribeUrl));
}
public async Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link)
/// <summary>
/// Recordatorio de carrito abandonado. Categoría: marketing.
/// </summary>
public async Task SendPaymentReminderEmailAsync(
string toEmail, string userName, string adTitle, string link,
string? unsubscribeUrl = null)
{
string subject = "Finaliza la publicación de tu aviso - Motores Argentinos";
string content = $@"
@@ -172,10 +229,14 @@ public class NotificationService : INotificationService
<a href='{link}' style='background-color: #10b981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>FINALIZAR PUBLICACIÓN</a>
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Acción Requerida", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Acción Requerida", content, unsubscribeUrl));
}
public async Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode)
/// <summary>
/// Recibo de pago transaccional. SIN enlace de baja (siempre se envía).
/// </summary>
public async Task SendPaymentReceiptEmailAsync(
string toEmail, string userName, string adTitle, decimal amount, string operationCode)
{
string subject = "Comprobante de Pago - Motores Argentinos";
string content = $@"
@@ -191,10 +252,16 @@ public class NotificationService : INotificationService
</div>
<p>Tu aviso ha pasado a la etapa de moderación y será activado a la brevedad.</p>";
// Sin unsubscribeUrl: los comprobantes de pago son transaccionales obligatorios
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Recibo de Pago", content));
}
public async Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount)
/// <summary>
/// Recordatorio de mensajes sin leer. Categoría: mensajes.
/// </summary>
public async Task SendUnreadMessagesReminderEmailAsync(
string toEmail, string userName, int unreadCount,
string? unsubscribeUrl = null)
{
string subject = "Tienes mensajes sin leer - Motores Argentinos";
string content = $@"
@@ -205,6 +272,6 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>IR A MIS MENSAJES</a>
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content));
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content, unsubscribeUrl));
}
}