using System.Globalization; using line_gestao_api.Data; using line_gestao_api.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace line_gestao_api.Services; public class VigenciaNotificationBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly NotificationOptions _options; public VigenciaNotificationBackgroundService( IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; _options = options.Value; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var intervalMinutes = _options.CheckIntervalMinutes <= 0 ? 60 : _options.CheckIntervalMinutes; using var timer = new PeriodicTimer(TimeSpan.FromMinutes(intervalMinutes)); await RunOnceAsync(stoppingToken); while (await timer.WaitForNextTickAsync(stoppingToken)) { await RunOnceAsync(stoppingToken); } } private async Task RunOnceAsync(CancellationToken stoppingToken) { try { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var tenantProvider = scope.ServiceProvider.GetRequiredService(); if (!await TableExistsAsync(db, "Notifications", stoppingToken)) { _logger.LogWarning("Tabela Notifications ainda não existe. Aguardando migrations."); return; } var tenants = await db.Tenants.AsNoTracking().ToListAsync(stoppingToken); if (tenants.Count == 0) { _logger.LogWarning("Nenhum tenant encontrado para gerar notificações."); return; } foreach (var tenant in tenants) { tenantProvider.SetTenantId(tenant.Id); await ProcessTenantAsync(db, tenant.Id, stoppingToken); } tenantProvider.SetTenantId(null); } catch (Exception ex) { _logger.LogError(ex, "Erro ao gerar notificações de vigência."); } } private static async Task TableExistsAsync(AppDbContext db, string tableName, CancellationToken stoppingToken) { if (!db.Database.IsRelational()) { return true; } var connection = db.Database.GetDbConnection(); if (connection.State != System.Data.ConnectionState.Open) { await connection.OpenAsync(stoppingToken); } await using var command = connection.CreateCommand(); command.CommandText = "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = @tableName)"; var parameter = command.CreateParameter(); parameter.ParameterName = "tableName"; parameter.Value = tableName; command.Parameters.Add(parameter); var result = await command.ExecuteScalarAsync(stoppingToken); return result is bool exists && exists; } private async Task ProcessTenantAsync(AppDbContext db, Guid tenantId, CancellationToken stoppingToken) { var today = DateTime.UtcNow.Date; var reminderDays = _options.ReminderDays .Distinct() .Where(d => d > 0) .OrderBy(d => d) .ToList(); var users = await db.Users.AsNoTracking() .Select(u => new { u.Id, u.Name, u.Email }) .ToListAsync(stoppingToken); var userByName = users .Where(u => !string.IsNullOrWhiteSpace(u.Name)) .ToDictionary(u => u.Name.Trim().ToLowerInvariant(), u => u.Id); var userByEmail = users .Where(u => !string.IsNullOrWhiteSpace(u.Email)) .ToDictionary(u => u.Email.Trim().ToLowerInvariant(), u => u.Id); var vigencias = await db.VigenciaLines.AsNoTracking() .Where(v => v.DtTerminoFidelizacao != null) .ToListAsync(stoppingToken); if (vigencias.Count > 0) { await CleanupOutdatedNotificationsAsync(db, vigencias, reminderDays, today, stoppingToken); } var candidates = new List(); foreach (var vigencia in vigencias) { if (vigencia.DtTerminoFidelizacao is null) { continue; } var endDate = vigencia.DtTerminoFidelizacao.Value.Date; var usuario = vigencia.Usuario?.Trim(); var cliente = vigencia.Cliente?.Trim(); var linha = vigencia.Linha?.Trim(); var usuarioKey = usuario?.ToLowerInvariant(); Guid? userId = null; if (!string.IsNullOrWhiteSpace(usuarioKey)) { if (userByEmail.TryGetValue(usuarioKey, out var matchedByEmail)) { userId = matchedByEmail; } else if (userByName.TryGetValue(usuarioKey, out var matchedByName)) { userId = matchedByName; } } if (endDate < today) { var notification = BuildNotification( tipo: "Vencido", titulo: $"Linha vencida{FormatLinha(linha)}", mensagem: $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} venceu em {endDate:dd/MM/yyyy}.", referenciaData: endDate, diasParaVencer: 0, userId: userId, usuario: usuario, cliente: cliente, linha: linha, vigenciaLineId: vigencia.Id, tenantId: tenantId); candidates.Add(notification); continue; } var daysUntil = (endDate - today).Days; if (reminderDays.Contains(daysUntil)) { var notification = BuildNotification( tipo: "AVencer", titulo: $"Linha a vencer em {daysUntil} dias{FormatLinha(linha)}", mensagem: $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} vence em {endDate:dd/MM/yyyy}.", referenciaData: endDate, diasParaVencer: daysUntil, userId: userId, usuario: usuario, cliente: cliente, linha: linha, vigenciaLineId: vigencia.Id, tenantId: tenantId); candidates.Add(notification); } } if (candidates.Count == 0) { return; } var candidateTipos = candidates.Select(c => c.Tipo).Distinct().ToList(); var candidateDates = candidates .Where(c => c.ReferenciaData.HasValue) .Select(c => c.ReferenciaData!.Value.Date) .Distinct() .ToList(); var existingNotifications = await db.Notifications.AsNoTracking() .Where(n => n.TenantId == tenantId) .Where(n => candidateTipos.Contains(n.Tipo)) .Where(n => n.ReferenciaData != null && candidateDates.Contains(n.ReferenciaData.Value.Date)) .ToListAsync(stoppingToken); var existingSet = new HashSet(existingNotifications.Select(n => BuildDedupKey( n.Tipo, n.ReferenciaData!.Value, n.DiasParaVencer ?? 0, n.Usuario, n.Cliente, n.Linha))); var toInsert = candidates .Where(c => !existingSet.Contains(c.DedupKey)) .ToList(); if (toInsert.Count == 0) { return; } await db.Notifications.AddRangeAsync(toInsert, stoppingToken); await db.SaveChangesAsync(stoppingToken); } private static async Task CleanupOutdatedNotificationsAsync( AppDbContext db, IReadOnlyCollection vigencias, IReadOnlyCollection reminderDays, DateTime today, CancellationToken stoppingToken) { var vigenciasById = vigencias.ToDictionary(v => v.Id, v => v); var vigenciasByLinha = vigencias .Where(v => !string.IsNullOrWhiteSpace(v.Linha)) .GroupBy(v => v.Linha!) .Select(g => g.OrderByDescending(v => v.UpdatedAt).First()) .ToDictionary(v => v.Linha!, v => v); var existingNotifications = await db.Notifications.AsNoTracking() .Where(n => n.Tipo == "Vencido" || n.Tipo == "AVencer") .ToListAsync(stoppingToken); if (existingNotifications.Count == 0) { return; } var idsToDelete = new List(); foreach (var notification in existingNotifications) { var vigencia = ResolveVigencia(notification, vigenciasById, vigenciasByLinha); if (vigencia?.DtTerminoFidelizacao is null) { continue; } var endDate = vigencia.DtTerminoFidelizacao.Value.Date; if (endDate < today) { if (notification.Tipo != "Vencido") { idsToDelete.Add(notification.Id); } continue; } var daysUntil = (endDate - today).Days; if (notification.Tipo == "Vencido") { idsToDelete.Add(notification.Id); continue; } if (!reminderDays.Contains(daysUntil) || notification.DiasParaVencer != daysUntil) { idsToDelete.Add(notification.Id); } } if (idsToDelete.Count == 0) { return; } await db.Notifications .Where(n => idsToDelete.Contains(n.Id)) .ExecuteDeleteAsync(stoppingToken); } private static VigenciaLine? ResolveVigencia( Notification notification, IReadOnlyDictionary vigenciasById, IReadOnlyDictionary vigenciasByLinha) { if (notification.VigenciaLineId.HasValue && vigenciasById.TryGetValue(notification.VigenciaLineId.Value, out var byId)) { return byId; } if (!string.IsNullOrWhiteSpace(notification.Linha) && vigenciasByLinha.TryGetValue(notification.Linha, out var byLinha)) { return byLinha; } return null; } private static Notification BuildNotification( string tipo, string titulo, string mensagem, DateTime referenciaData, int diasParaVencer, Guid? userId, string? usuario, string? cliente, string? linha, Guid vigenciaLineId, Guid tenantId) { return new Notification { Tipo = tipo, Titulo = titulo, Mensagem = mensagem, Data = DateTime.UtcNow, ReferenciaData = referenciaData, DiasParaVencer = diasParaVencer, Lida = false, DedupKey = BuildDedupKey(tipo, referenciaData, diasParaVencer, usuario, cliente, linha), UserId = userId, Usuario = usuario, Cliente = cliente, Linha = linha, VigenciaLineId = vigenciaLineId, TenantId = tenantId }; } private static string BuildDedupKey( string tipo, DateTime referenciaData, int diasParaVencer, string? usuario, string? cliente, string? linha) { var parts = new[] { tipo.Trim().ToLowerInvariant(), referenciaData.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), diasParaVencer.ToString(CultureInfo.InvariantCulture), (usuario ?? string.Empty).Trim().ToLowerInvariant(), (cliente ?? string.Empty).Trim().ToLowerInvariant(), (linha ?? string.Empty).Trim().ToLowerInvariant() }; return string.Join('|', parts); } private static string FormatLinha(string? linha) { return string.IsNullOrWhiteSpace(linha) ? string.Empty : $" {linha}"; } }