// 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 _logger; public AdExpirationService(IServiceProvider serviceProvider, ILogger 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(); // Ejecutar cada 1 hora await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento."); await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } } } private async Task CleanupAdViewLogsAsync() { using (var scope = _serviceProvider.CreateScope()) { var context = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); // 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(); var notifService = scope.ServiceProvider.GetRequiredService(); 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(); var notifService = scope.ServiceProvider.GetRequiredService(); 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(); var notifService = scope.ServiceProvider.GetRequiredService(); 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(); var notifService = scope.ServiceProvider.GetRequiredService(); var config = scope.ServiceProvider.GetRequiredService(); 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(); var notifService = scope.ServiceProvider.GetRequiredService(); // 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(); var imageService = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); // 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(); var imageService = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); 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(); var logger = scope.ServiceProvider.GetRequiredService>(); // 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. } } } }