Sistema de Notificaciones y Baja One-Click
This commit is contained in:
@@ -23,6 +23,8 @@ public class MotoresV2DbContext : DbContext
|
||||
public DbSet<PaymentMethod> PaymentMethods { get; set; }
|
||||
public DbSet<RefreshToken> RefreshTokens { get; set; }
|
||||
public DbSet<AdViewLog> AdViewLogs { get; set; }
|
||||
public DbSet<UserNotificationPreference> NotificationPreferences { get; set; }
|
||||
public DbSet<UnsubscribeToken> UnsubscribeTokens { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -67,8 +69,31 @@ public class MotoresV2DbContext : DbContext
|
||||
modelBuilder.Entity<AdFeature>().ToTable("AdFeatures");
|
||||
modelBuilder.Entity<TransactionRecord>().ToTable("Transactions");
|
||||
|
||||
// Configuración de AdViewLog
|
||||
modelBuilder.Entity<AdViewLog>().ToTable("AdViewLogs");
|
||||
modelBuilder.Entity<AdViewLog>().HasIndex(l => new { l.AdID, l.IPAddress, l.ViewDate });
|
||||
|
||||
// Configuración de UserNotificationPreference
|
||||
modelBuilder.Entity<UserNotificationPreference>().ToTable("UserNotificationPreferences");
|
||||
modelBuilder.Entity<UserNotificationPreference>().HasKey(p => p.PreferenceID);
|
||||
// Índice único: un usuario no puede tener dos registros para la misma categoría
|
||||
modelBuilder.Entity<UserNotificationPreference>()
|
||||
.HasIndex(p => new { p.UserID, p.Category })
|
||||
.IsUnique();
|
||||
modelBuilder.Entity<UserNotificationPreference>()
|
||||
.HasOne(p => p.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.UserID)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Configuración de UnsubscribeToken
|
||||
modelBuilder.Entity<UnsubscribeToken>().ToTable("UnsubscribeTokens");
|
||||
modelBuilder.Entity<UnsubscribeToken>().HasKey(t => t.TokenID);
|
||||
// Índice para búsqueda rápida por valor del token
|
||||
modelBuilder.Entity<UnsubscribeToken>().HasIndex(t => t.Token).IsUnique();
|
||||
modelBuilder.Entity<UnsubscribeToken>()
|
||||
.HasOne(t => t.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.UserID)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user