Init Commit

This commit is contained in:
2026-01-29 13:43:44 -03:00
commit b9aa8478db
126 changed files with 20649 additions and 0 deletions

View File

@@ -0,0 +1,448 @@
// 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.
}
}
}
}