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

@@ -18,12 +18,21 @@ public class AdminController : ControllerBase
private readonly MotoresV2DbContext _context;
private readonly IAdSyncService _syncService;
private readonly INotificationService _notificationService;
private readonly INotificationPreferenceService _prefService;
private readonly string _frontendUrl;
public AdminController(MotoresV2DbContext context, IAdSyncService syncService, INotificationService notificationService)
public AdminController(
MotoresV2DbContext context,
IAdSyncService syncService,
INotificationService notificationService,
INotificationPreferenceService prefService,
IConfiguration config)
{
_context = context;
_syncService = syncService;
_notificationService = notificationService;
_prefService = prefService;
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
}
// --- MODERACIÓN ---
@@ -160,12 +169,22 @@ public class AdminController : ControllerBase
});
await _context.SaveChangesAsync();
// Sincronizar a Legacy
// Sincronizar a Legacy y notificar aprobación (categoría: sistema)
try
{
await _syncService.SyncAdToLegacyAsync(id);
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "APROBADO");
// Generamos el token de baja para la categoría "sistema"
string? unsubscribeUrl = null;
if (ad.User != null)
{
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
}
await _notificationService.SendAdStatusChangedEmailAsync(
ad.User?.Email ?? string.Empty, adTitle, "APROBADO", null, unsubscribeUrl);
}
catch (Exception)
{
@@ -197,9 +216,19 @@ public class AdminController : ControllerBase
});
await _context.SaveChangesAsync();
// Notificar rechazo
// Notificar rechazo (categoría: sistema)
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "RECHAZADO", reason);
// Generamos el token de baja para la categoría "sistema"
string? unsubscribeUrl = null;
if (ad.User != null)
{
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
}
await _notificationService.SendAdStatusChangedEmailAsync(
ad.User?.Email ?? string.Empty, adTitle, "RECHAZADO", reason, unsubscribeUrl);
return Ok(new { message = "Aviso rechazado." });
}

View File

@@ -14,11 +14,19 @@ public class ChatController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly INotificationService _notificationService;
private readonly INotificationPreferenceService _prefService;
private readonly string _frontendUrl;
public ChatController(MotoresV2DbContext context, INotificationService notificationService)
public ChatController(
MotoresV2DbContext context,
INotificationService notificationService,
INotificationPreferenceService prefService,
IConfiguration config)
{
_context = context;
_notificationService = notificationService;
_prefService = prefService;
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
}
[HttpPost("send")]
@@ -39,26 +47,35 @@ public class ChatController : ControllerBase
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
{
// LÓGICA DE NOMBRE DE REMITENTE
string senderDisplayName;
if (sender != null && sender.UserType == 3) // 3 = ADMIN
// Solo enviar correo si la preferencia "mensajes" está habilitada
if (await _prefService.IsEnabledAsync(receiver.UserID, NotificationCategory.Mensajes))
{
// Caso: Moderador escribe a Usuario
senderDisplayName = "Un moderador de Motores Argentinos";
}
else
{
// Caso: Usuario responde a Moderador
string name = sender?.UserName ?? "Un usuario";
senderDisplayName = $"El usuario {name}";
}
// LÓGICA DE NOMBRE DE REMITENTE
string senderDisplayName;
await _notificationService.SendChatNotificationEmailAsync(
receiver.Email,
senderDisplayName, // Pasamos el nombre formateado
msg.MessageText,
msg.AdID);
if (sender != null && sender.UserType == 3) // 3 = ADMIN
{
// Caso: Moderador escribe a Usuario
senderDisplayName = "Un moderador de Motores Argentinos";
}
else
{
// Caso: Usuario responde a Moderador
string name = sender?.UserName ?? "Un usuario";
senderDisplayName = $"El usuario {name}";
}
// Generamos el token de baja para la categoría "mensajes"
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(receiver.UserID, NotificationCategory.Mensajes);
var unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await _notificationService.SendChatNotificationEmailAsync(
receiver.Email,
senderDisplayName, // Pasamos el nombre formateado
msg.MessageText,
msg.AdID,
unsubscribeUrl); // Se incluye URL de baja en el footer
}
}
}
catch (Exception ex)

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
@@ -14,10 +15,14 @@ namespace MotoresArgentinosV2.API.Controllers;
public class ProfileController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly INotificationPreferenceService _notifPrefService;
public ProfileController(MotoresV2DbContext context)
public ProfileController(
MotoresV2DbContext context,
INotificationPreferenceService notifPrefService)
{
_context = context;
_notifPrefService = notifPrefService;
}
[HttpGet]
@@ -71,4 +76,43 @@ public class ProfileController : ControllerBase
return Ok(new { message = "Perfil actualizado con éxito." });
}
// ─── Preferencias de Notificación ────────────────────────────────────────
/// <summary>
/// Obtiene las preferencias de notificación del usuario autenticado.
/// GET api/profile/notification-preferences
/// </summary>
[HttpGet("notification-preferences")]
public async Task<IActionResult> GetNotificationPreferences()
{
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
var prefs = await _notifPrefService.GetPreferencesAsync(userId);
return Ok(prefs);
}
/// <summary>
/// Actualiza las preferencias de notificación del usuario autenticado.
/// PUT api/profile/notification-preferences
/// </summary>
[HttpPut("notification-preferences")]
public async Task<IActionResult> UpdateNotificationPreferences(
[FromBody] UpdateNotificationPreferencesDto dto)
{
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
await _notifPrefService.UpdatePreferencesAsync(userId, dto);
// Registramos en auditoría
_context.AuditLogs.Add(new AuditLog
{
Action = "NOTIFICATION_PREFS_UPDATED",
Entity = "User",
EntityID = userId,
UserID = userId,
Details = "Usuario actualizó sus preferencias de notificación."
});
await _context.SaveChangesAsync();
return Ok(new { message = "Preferencias actualizadas con éxito." });
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
/// <summary>
/// Controlador PÚBLICO (sin autenticación) para gestionar la baja de correos.
/// El token del enlace garantiza que no se puede dar de baja a otro usuario.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class UnsubscribeController : ControllerBase
{
private readonly INotificationPreferenceService _prefService;
private readonly IConfiguration _config;
public UnsubscribeController(
INotificationPreferenceService prefService,
IConfiguration config)
{
_prefService = prefService;
_config = config;
}
/// <summary>
/// Procesa la baja one-click desde el enlace del correo.
/// GET api/unsubscribe?token=xxxxx
/// Redirige al frontend con el resultado para mostrar una página amigable.
/// </summary>
[HttpGet]
public async Task<IActionResult> Unsubscribe([FromQuery] string token)
{
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { success = false, message = "Token inválido o faltante." });
var (success, categoryLabel) = await _prefService.UnsubscribeAsync(token);
if (success)
{
return Ok(new { success = true, category = categoryLabel });
}
return BadRequest(new { success = false, message = "El enlace de baja ha expirado o no es válido." });
}
}