448 lines
15 KiB
C#
448 lines
15 KiB
C#
// backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using MotoresArgentinosV2.Infrastructure.Data;
|
|
using MotoresArgentinosV2.Core.Entities;
|
|
using MotoresArgentinosV2.Core.Interfaces;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace MotoresArgentinosV2.Infrastructure.Services;
|
|
|
|
public class AdExpirationService : BackgroundService
|
|
{
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly ILogger<AdExpirationService> _logger;
|
|
|
|
public AdExpirationService(IServiceProvider serviceProvider, ILogger<AdExpirationService> logger)
|
|
{
|
|
_serviceProvider = serviceProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("Servicio de Mantenimiento Integral iniciado.");
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
// 1. Vencimientos
|
|
await CheckExpiredAdsAsync();
|
|
await ProcessExpirationWarningsAsync();
|
|
|
|
// 2. Limpiezas
|
|
await CleanupUnpaidDraftsAsync();
|
|
await PermanentDeleteOldDeletedAdsAsync();
|
|
await CleanupOldRefreshTokensAsync();
|
|
await CleanupAdViewLogsAsync();
|
|
|
|
// 3. Marketing y Retención
|
|
await ProcessWeeklyStatsAsync();
|
|
await ProcessPaymentRemindersAsync();
|
|
await ProcessUnreadMessagesRemindersAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento.");
|
|
}
|
|
|
|
// Ejecutar cada 1 hora
|
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
|
}
|
|
}
|
|
|
|
private async Task CleanupAdViewLogsAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
|
|
|
// Borrar logs de visitas de más de 30 días de antigüedad
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
|
|
|
var deletedCount = await context.AdViewLogs
|
|
.Where(l => l.ViewDate < cutoffDate)
|
|
.ExecuteDeleteAsync();
|
|
|
|
if (deletedCount > 0)
|
|
{
|
|
logger.LogInformation("Mantenimiento: Se eliminaron {Count} registros de visitas antiguos.", deletedCount);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task CheckExpiredAdsAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
|
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
|
|
|
var expiredAds = await context.Ads
|
|
.Include(a => a.User)
|
|
.Include(a => a.Brand)
|
|
.Where(a =>
|
|
// Regla 1: Aviso activo
|
|
a.StatusID == (int)AdStatusEnum.Active &&
|
|
// Regla 2: Publicado hace más de 30 días
|
|
a.PublishedAt.HasValue && a.PublishedAt.Value < cutoffDate &&
|
|
// --- CAMBIO AQUÍ: Excluimos avisos de administradores ---
|
|
a.User != null && a.User.UserType != 3
|
|
)
|
|
.ToListAsync();
|
|
|
|
foreach (var ad in expiredAds)
|
|
{
|
|
ad.StatusID = (int)AdStatusEnum.Expired;
|
|
|
|
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email))
|
|
{
|
|
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
|
await notifService.SendAdExpiredEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title);
|
|
}
|
|
|
|
context.AuditLogs.Add(new AuditLog
|
|
{
|
|
Action = "SYSTEM_AD_EXPIRED",
|
|
Entity = "Ad",
|
|
EntityID = ad.AdID,
|
|
UserID = 0,
|
|
Details = $"Aviso ID {ad.AdID} vencido. Email enviado a usuario no-admin."
|
|
});
|
|
}
|
|
if (expiredAds.Any()) await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ProcessExpirationWarningsAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
|
|
|
var warningThreshold = DateTime.UtcNow.AddDays(-25);
|
|
|
|
var adsToWarn = await context.Ads
|
|
.Include(a => a.User)
|
|
.Include(a => a.Brand)
|
|
.Where(a =>
|
|
a.StatusID == (int)AdStatusEnum.Active &&
|
|
a.PublishedAt.HasValue && a.PublishedAt.Value <= warningThreshold &&
|
|
!a.ExpirationWarningSent &&
|
|
// --- CAMBIO AQUÍ: Excluimos avisos de administradores ---
|
|
a.User != null && a.User.UserType != 3
|
|
)
|
|
.ToListAsync();
|
|
|
|
foreach (var ad in adsToWarn)
|
|
{
|
|
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
|
|
|
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);
|
|
ad.ExpirationWarningSent = true;
|
|
}
|
|
catch { /* Log error pero continuar */ }
|
|
}
|
|
|
|
if (adsToWarn.Any()) await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ProcessWeeklyStatsAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
|
|
|
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
|
|
|
var adsForStats = await context.Ads
|
|
.Include(a => a.User)
|
|
.Include(a => a.Brand)
|
|
.Where(a =>
|
|
a.StatusID == (int)AdStatusEnum.Active &&
|
|
a.User != null && a.User.UserType != 3 && // Ya estaba excluido aquí
|
|
a.PublishedAt.HasValue && a.PublishedAt.Value <= sevenDaysAgo &&
|
|
(a.LastPerformanceEmailSentAt == null || a.LastPerformanceEmailSentAt <= sevenDaysAgo)
|
|
)
|
|
.Take(50)
|
|
.ToListAsync();
|
|
|
|
foreach (var ad in adsForStats)
|
|
{
|
|
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
|
|
|
var favCount = await context.Favorites.CountAsync(f => f.AdID == ad.AdID);
|
|
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
|
|
|
await notifService.SendWeeklyPerformanceEmailAsync(
|
|
ad.User.Email,
|
|
ad.User.FirstName ?? "Usuario",
|
|
title,
|
|
ad.ViewsCounter,
|
|
favCount
|
|
);
|
|
|
|
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
|
|
}
|
|
|
|
if (adsForStats.Any()) await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ProcessPaymentRemindersAsync()
|
|
{
|
|
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"] ?? "http://localhost:5173";
|
|
|
|
var cutoff = DateTime.UtcNow.AddHours(-24);
|
|
|
|
var abandonedCarts = await context.Ads
|
|
.Include(a => a.User)
|
|
.Include(a => a.Brand)
|
|
.Where(a =>
|
|
(a.StatusID == 1 || a.StatusID == 2) &&
|
|
a.CreatedAt < cutoff &&
|
|
a.PaymentReminderSentAt == null &&
|
|
a.User != null && a.User.UserType != 3
|
|
)
|
|
.Take(20)
|
|
.ToListAsync();
|
|
|
|
foreach (var ad in abandonedCarts)
|
|
{
|
|
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
|
|
|
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);
|
|
|
|
ad.PaymentReminderSentAt = DateTime.UtcNow;
|
|
}
|
|
|
|
if (abandonedCarts.Any()) await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ProcessUnreadMessagesRemindersAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
|
|
|
// 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);
|
|
var notificationThreshold = DateTime.UtcNow.AddHours(-24);
|
|
|
|
// 1. Obtener IDs de usuarios con mensajes sin leer viejos
|
|
var usersWithUnread = await context.ChatMessages
|
|
.Where(m => !m.IsRead && m.SentAt < messageThreshold)
|
|
.Select(m => m.ReceiverID)
|
|
.Distinct()
|
|
.ToListAsync();
|
|
|
|
foreach (var userId in usersWithUnread)
|
|
{
|
|
var user = await context.Users.FindAsync(userId);
|
|
|
|
// Verificar si ya le avisamos hoy
|
|
if (user == null || string.IsNullOrEmpty(user.Email) ||
|
|
(user.LastUnreadMessageReminderSentAt != null && user.LastUnreadMessageReminderSentAt > notificationThreshold))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 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);
|
|
|
|
user.LastUnreadMessageReminderSentAt = DateTime.UtcNow;
|
|
}
|
|
|
|
if (usersWithUnread.Any()) await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task CleanupUnpaidDraftsAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var imageService = scope.ServiceProvider.GetRequiredService<IImageStorageService>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
|
|
|
// Regla: Eliminar avisos IMPAGOS (Borrador = 1) con más de 30 días de antigüedad (CreatedAt).
|
|
// No se tocan los que están en Moderación (3) ni los Rechazados (5) a menos que se especifique.
|
|
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
|
|
|
var oldDrafts = await context.Ads
|
|
.Include(a => a.Photos)
|
|
.Where(a => a.StatusID == 1 && a.CreatedAt < cutoffDate) // 1 = Borrador/Impago
|
|
.ToListAsync();
|
|
|
|
if (oldDrafts.Any())
|
|
{
|
|
logger.LogInformation("Eliminando {Count} avisos impagos (borradores) de más de 30 días de antigüedad...", oldDrafts.Count);
|
|
|
|
foreach (var draft in oldDrafts)
|
|
{
|
|
try
|
|
{
|
|
// 1. Borrar fotos del disco físico
|
|
if (draft.Photos != null)
|
|
{
|
|
foreach (var photo in draft.Photos)
|
|
{
|
|
if (!string.IsNullOrEmpty(photo.FilePath))
|
|
imageService.DeleteAdImage(photo.FilePath);
|
|
}
|
|
}
|
|
|
|
// 2. Eliminar registro de la DB
|
|
context.Ads.Remove(draft);
|
|
|
|
// 📝 AUDITORÍA
|
|
context.AuditLogs.Add(new AuditLog
|
|
{
|
|
Action = "SYSTEM_DRAFT_CLEANED",
|
|
Entity = "Ad",
|
|
EntityID = draft.AdID,
|
|
UserID = 0, // Sistema
|
|
Details = $"Borrador impago ID {draft.AdID} eliminado por antigüedad."
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error eliminando borrador ID {AdId}", draft.AdID);
|
|
}
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
logger.LogInformation("Limpieza de borradores antiguos completada.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task PermanentDeleteOldDeletedAdsAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var imageService = scope.ServiceProvider.GetRequiredService<IImageStorageService>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
|
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-60);
|
|
|
|
var adsToRemove = await context.Ads
|
|
.Include(a => a.Photos)
|
|
.Include(a => a.Features)
|
|
.Include(a => a.Messages)
|
|
.Where(a => a.StatusID == (int)AdStatusEnum.Deleted
|
|
&& a.DeletedAt.HasValue
|
|
&& a.DeletedAt.Value < cutoffDate)
|
|
.ToListAsync();
|
|
|
|
if (adsToRemove.Any())
|
|
{
|
|
logger.LogInformation("Eliminando permanentemente {Count} avisos...", adsToRemove.Count);
|
|
|
|
foreach (var ad in adsToRemove)
|
|
{
|
|
try
|
|
{
|
|
// 1. Borrar fotos del disco físico
|
|
if (ad.Photos != null)
|
|
{
|
|
foreach (var photo in ad.Photos)
|
|
{
|
|
if (!string.IsNullOrEmpty(photo.FilePath))
|
|
imageService.DeleteAdImage(photo.FilePath);
|
|
}
|
|
}
|
|
|
|
// 2. Info para el log antes de borrar
|
|
int msgCount = ad.Messages?.Count ?? 0;
|
|
|
|
// 3. Eliminar registro de la DB
|
|
// Al tener Cascade configurado en EF y SQL, esto borrará:
|
|
// - El Aviso (Ad)
|
|
// - Sus Fotos (AdPhotos)
|
|
// - Sus Características (AdFeatures)
|
|
// - Sus Mensajes (ChatMessages)
|
|
context.Ads.Remove(ad);
|
|
|
|
// 📝 AUDITORÍA
|
|
context.AuditLogs.Add(new AuditLog
|
|
{
|
|
Action = "SYSTEM_HARD_DELETE",
|
|
Entity = "Ad",
|
|
EntityID = ad.AdID,
|
|
UserID = 0, // Sistema
|
|
Details = $"Aviso ID {ad.AdID} eliminado permanentemente. Se eliminaron {msgCount} mensajes de chat asociados."
|
|
});
|
|
|
|
logger.LogInformation("Hard Delete AdID {AdId} completado. Mensajes eliminados: {MsgCount}", ad.AdID, msgCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error en Hard Delete del aviso ID {AdId}", ad.AdID);
|
|
}
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
}
|
|
private async Task CleanupOldRefreshTokensAsync()
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
|
|
|
// Política: Eliminar tokens que:
|
|
// 1. Ya expiraron hace más de 30 días OR
|
|
// 2. Fueron revocados hace más de 30 días
|
|
// (Mantenemos un historial de 30 días por seguridad/auditoría, luego se borra)
|
|
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
|
|
|
// Usamos ExecuteDeleteAsync para borrado masivo eficiente (EF Core 7+)
|
|
var deletedCount = await context.RefreshTokens
|
|
.Where(t => t.Expires < cutoffDate || (t.Revoked != null && t.Revoked < cutoffDate))
|
|
.ExecuteDeleteAsync();
|
|
|
|
if (deletedCount > 0)
|
|
{
|
|
logger.LogInformation("Limpieza de Tokens: Se eliminaron {Count} refresh tokens obsoletos.", deletedCount);
|
|
|
|
// No hace falta guardar AuditLog para esto, es mantenimiento técnico puro,
|
|
// pero se podría agregar.
|
|
}
|
|
}
|
|
}
|
|
} |