diff --git a/Controllers/NotificationsController.cs b/Controllers/NotificationsController.cs new file mode 100644 index 0000000..b9e2e9c --- /dev/null +++ b/Controllers/NotificationsController.cs @@ -0,0 +1,108 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/notifications")] +[Authorize] +public class NotificationsController : ControllerBase +{ + private readonly AppDbContext _db; + + public NotificationsController(AppDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task>> GetNotifications() + { + var (userId, userName) = GetUserContext(); + if (userId is null && string.IsNullOrWhiteSpace(userName)) + { + return Unauthorized(); + } + + var userNameNormalized = userName?.Trim(); + + var query = _db.Notifications.AsNoTracking() + .Where(n => + (userId != null && n.UserId == userId) || + (userNameNormalized != null && n.Usuario != null && + EF.Functions.ILike(n.Usuario, userNameNormalized))); + + var items = await query + .OrderByDescending(n => n.Data) + .Select(n => new NotificationDto + { + Id = n.Id, + Tipo = n.Tipo, + Titulo = n.Titulo, + Mensagem = n.Mensagem, + Data = n.Data, + ReferenciaData = n.ReferenciaData, + DiasParaVencer = n.DiasParaVencer, + Lida = n.Lida, + LidaEm = n.LidaEm, + VigenciaLineId = n.VigenciaLineId, + Cliente = n.Cliente, + Linha = n.Linha + }) + .ToListAsync(); + + return Ok(items); + } + + [HttpPatch("{id:guid}/read")] + public async Task MarkAsRead(Guid id) + { + var (userId, userName) = GetUserContext(); + if (userId is null && string.IsNullOrWhiteSpace(userName)) + { + return Unauthorized(); + } + + var userNameNormalized = userName?.Trim(); + + var notification = await _db.Notifications + .FirstOrDefaultAsync(n => n.Id == id && + ((userId != null && n.UserId == userId) || + (userNameNormalized != null && n.Usuario != null && + EF.Functions.ILike(n.Usuario, userNameNormalized)))); + + if (notification is null) + { + return NotFound(); + } + + if (!notification.Lida) + { + notification.Lida = true; + notification.LidaEm = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } + + return NoContent(); + } + + private (Guid? UserId, string? UserName) GetUserContext() + { + var userIdRaw = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + + Guid? userId = null; + if (Guid.TryParse(userIdRaw, out var parsed)) + { + userId = parsed; + } + + var userName = User.FindFirstValue("name"); + return (userId, userName); + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 4591868..ba6c659 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -27,6 +27,9 @@ public class AppDbContext : DbContext // ✅ tabela TROCA DE NÚMERO public DbSet TrocaNumeroLines => Set(); + // ✅ tabela NOTIFICAÇÕES + public DbSet Notifications => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -127,5 +130,28 @@ public class AppDbContext : DbContext e.HasIndex(x => x.ICCID); e.HasIndex(x => x.DataTroca); }); + + // ========================= + // ✅ NOTIFICAÇÕES + // ========================= + modelBuilder.Entity(e => + { + e.HasIndex(x => x.DedupKey).IsUnique(); + e.HasIndex(x => x.UserId); + e.HasIndex(x => x.Cliente); + e.HasIndex(x => x.Lida); + e.HasIndex(x => x.Data); + e.HasIndex(x => x.VigenciaLineId); + + e.HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Restrict); + + e.HasOne(x => x.VigenciaLine) + .WithMany() + .HasForeignKey(x => x.VigenciaLineId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/Dtos/NotificationDto.cs b/Dtos/NotificationDto.cs new file mode 100644 index 0000000..0d7c7e7 --- /dev/null +++ b/Dtos/NotificationDto.cs @@ -0,0 +1,17 @@ +namespace line_gestao_api.Dtos; + +public class NotificationDto +{ + public Guid Id { get; set; } + public string Tipo { get; set; } = string.Empty; + public string Titulo { get; set; } = string.Empty; + public string Mensagem { get; set; } = string.Empty; + public DateTime Data { get; set; } + public DateTime? ReferenciaData { get; set; } + public int? DiasParaVencer { get; set; } + public bool Lida { get; set; } + public DateTime? LidaEm { get; set; } + public Guid? VigenciaLineId { get; set; } + public string? Cliente { get; set; } + public string? Linha { get; set; } +} diff --git a/Migrations/20260205120000_AddNotifications.Designer.cs b/Migrations/20260205120000_AddNotifications.Designer.cs new file mode 100644 index 0000000..e163e89 --- /dev/null +++ b/Migrations/20260205120000_AddNotifications.Designer.cs @@ -0,0 +1,567 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260205120000_AddNotifications")] + partial class AddNotifications + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("line_gestao_api.Models.BillingClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aparelho") + .HasColumnType("text"); + + b.Property("Cliente") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FormaPagamento") + .HasColumnType("text"); + + b.Property("FranquiaLine") + .HasColumnType("numeric"); + + b.Property("FranquiaVivo") + .HasColumnType("numeric"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("Lucro") + .HasColumnType("numeric"); + + b.Property("QtdLinhas") + .HasColumnType("integer"); + + b.Property("Tipo") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValorContratoLine") + .HasColumnType("numeric"); + + b.Property("ValorContratoVivo") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Cliente"); + + b.HasIndex("Item"); + + b.HasIndex("Tipo"); + + b.HasIndex("Tipo", "Cliente"); + + b.ToTable("billing_clients", (string)null); + }); + + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cedente") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Chip") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Cliente") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Conta") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataBloqueio") + .HasColumnType("timestamp with time zone"); + + b.Property("DataEntregaCliente") + .HasColumnType("timestamp with time zone"); + + b.Property("DataEntregaOpera") + .HasColumnType("timestamp with time zone"); + + b.Property("Desconto") + .HasColumnType("numeric"); + + b.Property("FranquiaGestao") + .HasColumnType("numeric"); + + b.Property("FranquiaLine") + .HasColumnType("numeric"); + + b.Property("FranquiaVivo") + .HasColumnType("numeric"); + + b.Property("GestaoVozDados") + .HasColumnType("numeric"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("Linha") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("LocacaoAp") + .HasColumnType("numeric"); + + b.Property("Lucro") + .HasColumnType("numeric"); + + b.Property("Modalidade") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlanoContrato") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Skeelo") + .HasColumnType("numeric"); + + b.Property("Skil") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Solicitante") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Status") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Usuario") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ValorContratoLine") + .HasColumnType("numeric"); + + b.Property("ValorContratoVivo") + .HasColumnType("numeric"); + + b.Property("ValorPlanoVivo") + .HasColumnType("numeric"); + + b.Property("VencConta") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VivoGestaoDispositivo") + .HasColumnType("numeric"); + + b.Property("VivoNewsPlus") + .HasColumnType("numeric"); + + b.Property("VivoTravelMundo") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Chip"); + + b.HasIndex("Cliente"); + + b.HasIndex("Linha") + .IsUnique(); + + b.HasIndex("Skil"); + + b.HasIndex("Status"); + + b.HasIndex("Usuario"); + + b.ToTable("MobileLines"); + }); + + modelBuilder.Entity("line_gestao_api.Models.MuregLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataDaMureg") + .HasColumnType("timestamp with time zone"); + + b.Property("ICCID") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("LinhaAntiga") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("LinhaNova") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("MobileLineId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ICCID"); + + b.HasIndex("Item"); + + b.HasIndex("LinhaAntiga"); + + b.HasIndex("LinhaNova"); + + b.HasIndex("MobileLineId"); + + b.ToTable("MuregLines"); + }); + + modelBuilder.Entity("line_gestao_api.Models.TrocaNumeroLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataTroca") + .HasColumnType("timestamp with time zone"); + + b.Property("ICCID") + .HasColumnType("text"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("LinhaAntiga") + .HasColumnType("text"); + + b.Property("LinhaNova") + .HasColumnType("text"); + + b.Property("Motivo") + .HasColumnType("text"); + + b.Property("Observacao") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DataTroca"); + + b.HasIndex("ICCID"); + + b.HasIndex("Item"); + + b.HasIndex("LinhaAntiga"); + + b.HasIndex("LinhaNova"); + + b.ToTable("TrocaNumeroLines"); + }); + + modelBuilder.Entity("line_gestao_api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("line_gestao_api.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cliente") + .HasColumnType("text"); + + b.Property("Data") + .HasColumnType("timestamp with time zone"); + + b.Property("DedupKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiasParaVencer") + .HasColumnType("integer"); + + b.Property("Linha") + .HasColumnType("text"); + + b.Property("Lida") + .HasColumnType("boolean"); + + b.Property("LidaEm") + .HasColumnType("timestamp with time zone"); + + b.Property("Mensagem") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenciaData") + .HasColumnType("timestamp with time zone"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Usuario") + .HasColumnType("text"); + + b.Property("VigenciaLineId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Cliente"); + + b.HasIndex("Data"); + + b.HasIndex("DedupKey") + .IsUnique(); + + b.HasIndex("Lida"); + + b.HasIndex("UserId"); + + b.HasIndex("VigenciaLineId"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("line_gestao_api.Models.UserData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Celular") + .HasColumnType("text"); + + b.Property("Cliente") + .HasColumnType("text"); + + b.Property("Cpf") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataNascimento") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Endereco") + .HasColumnType("text"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("Linha") + .HasColumnType("text"); + + b.Property("Rg") + .HasColumnType("text"); + + b.Property("TelefoneFixo") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Cliente"); + + b.HasIndex("Cpf"); + + b.HasIndex("Email"); + + b.HasIndex("Item"); + + b.HasIndex("Linha"); + + b.ToTable("UserDatas"); + }); + + modelBuilder.Entity("line_gestao_api.Models.VigenciaLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cliente") + .HasColumnType("text"); + + b.Property("Conta") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DtEfetivacaoServico") + .HasColumnType("timestamp with time zone"); + + b.Property("DtTerminoFidelizacao") + .HasColumnType("timestamp with time zone"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("Linha") + .HasColumnType("text"); + + b.Property("PlanoContrato") + .HasColumnType("text"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Usuario") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Cliente"); + + b.HasIndex("DtTerminoFidelizacao"); + + b.HasIndex("Item"); + + b.HasIndex("Linha"); + + b.ToTable("VigenciaLines"); + }); + + modelBuilder.Entity("line_gestao_api.Models.Notification", b => + { + b.HasOne("line_gestao_api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("line_gestao_api.Models.VigenciaLine", "VigenciaLine") + .WithMany() + .HasForeignKey("VigenciaLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("User"); + + b.Navigation("VigenciaLine"); + }); + + modelBuilder.Entity("line_gestao_api.Models.MuregLine", b => + { + b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine") + .WithMany("Muregs") + .HasForeignKey("MobileLineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MobileLine"); + }); + + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => + { + b.Navigation("Muregs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260205120000_AddNotifications.cs b/Migrations/20260205120000_AddNotifications.cs new file mode 100644 index 0000000..2f73225 --- /dev/null +++ b/Migrations/20260205120000_AddNotifications.cs @@ -0,0 +1,90 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + /// + public partial class AddNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tipo = table.Column(type: "text", nullable: false), + Titulo = table.Column(type: "text", nullable: false), + Mensagem = table.Column(type: "text", nullable: false), + Data = table.Column(type: "timestamp with time zone", nullable: false), + ReferenciaData = table.Column(type: "timestamp with time zone", nullable: true), + DiasParaVencer = table.Column(type: "integer", nullable: true), + Lida = table.Column(type: "boolean", nullable: false), + LidaEm = table.Column(type: "timestamp with time zone", nullable: true), + DedupKey = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "uuid", nullable: true), + VigenciaLineId = table.Column(type: "uuid", nullable: true), + Usuario = table.Column(type: "text", nullable: true), + Cliente = table.Column(type: "text", nullable: true), + Linha = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + table.ForeignKey( + name: "FK_Notifications_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Notifications_VigenciaLines_VigenciaLineId", + column: x => x.VigenciaLineId, + principalTable: "VigenciaLines", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_Cliente", + table: "Notifications", + column: "Cliente"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_Data", + table: "Notifications", + column: "Data"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_DedupKey", + table: "Notifications", + column: "DedupKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_Lida", + table: "Notifications", + column: "Lida"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_UserId", + table: "Notifications", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_VigenciaLineId", + table: "Notifications", + column: "VigenciaLineId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 545aaff..a328ab9 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -348,6 +348,76 @@ namespace line_gestao_api.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("line_gestao_api.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cliente") + .HasColumnType("text"); + + b.Property("Data") + .HasColumnType("timestamp with time zone"); + + b.Property("DedupKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiasParaVencer") + .HasColumnType("integer"); + + b.Property("Linha") + .HasColumnType("text"); + + b.Property("Lida") + .HasColumnType("boolean"); + + b.Property("LidaEm") + .HasColumnType("timestamp with time zone"); + + b.Property("Mensagem") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenciaData") + .HasColumnType("timestamp with time zone"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Titulo") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Usuario") + .HasColumnType("text"); + + b.Property("VigenciaLineId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Cliente"); + + b.HasIndex("Data"); + + b.HasIndex("DedupKey") + .IsUnique(); + + b.HasIndex("Lida"); + + b.HasIndex("UserId"); + + b.HasIndex("VigenciaLineId"); + + b.ToTable("Notifications"); + }); + modelBuilder.Entity("line_gestao_api.Models.UserData", b => { b.Property("Id") @@ -457,6 +527,23 @@ namespace line_gestao_api.Migrations b.ToTable("VigenciaLines"); }); + modelBuilder.Entity("line_gestao_api.Models.Notification", b => + { + b.HasOne("line_gestao_api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("line_gestao_api.Models.VigenciaLine", "VigenciaLine") + .WithMany() + .HasForeignKey("VigenciaLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("User"); + + b.Navigation("VigenciaLine"); + }); + modelBuilder.Entity("line_gestao_api.Models.MuregLine", b => { b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine") diff --git a/Models/Notification.cs b/Models/Notification.cs new file mode 100644 index 0000000..8cef548 --- /dev/null +++ b/Models/Notification.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace line_gestao_api.Models; + +public class Notification +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public string Tipo { get; set; } = string.Empty; + + [Required] + public string Titulo { get; set; } = string.Empty; + + [Required] + public string Mensagem { get; set; } = string.Empty; + + public DateTime Data { get; set; } = DateTime.UtcNow; + + public DateTime? ReferenciaData { get; set; } + + public int? DiasParaVencer { get; set; } + + public bool Lida { get; set; } + + public DateTime? LidaEm { get; set; } + + [Required] + public string DedupKey { get; set; } = string.Empty; + + public Guid? UserId { get; set; } + public User? User { get; set; } + + public Guid? VigenciaLineId { get; set; } + public VigenciaLine? VigenciaLine { get; set; } + + public string? Usuario { get; set; } + public string? Cliente { get; set; } + public string? Linha { get; set; } +} diff --git a/Program.cs b/Program.cs index 1de23df..80288eb 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using System.Text; using line_gestao_api.Data; +using line_gestao_api.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http.Features; using Microsoft.EntityFrameworkCore; @@ -57,6 +58,9 @@ builder.Services builder.Services.AddAuthorization(); +builder.Services.Configure(builder.Configuration.GetSection("Notifications")); +builder.Services.AddHostedService(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) diff --git a/Services/NotificationOptions.cs b/Services/NotificationOptions.cs new file mode 100644 index 0000000..6627a5f --- /dev/null +++ b/Services/NotificationOptions.cs @@ -0,0 +1,7 @@ +namespace line_gestao_api.Services; + +public class NotificationOptions +{ + public int CheckIntervalMinutes { get; set; } = 60; + public List ReminderDays { get; set; } = new() { 30, 15, 7 }; +} diff --git a/Services/VigenciaNotificationBackgroundService.cs b/Services/VigenciaNotificationBackgroundService.cs new file mode 100644 index 0000000..63193e1 --- /dev/null +++ b/Services/VigenciaNotificationBackgroundService.cs @@ -0,0 +1,219 @@ +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 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); + + 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); + + 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); + + candidates.Add(notification); + } + } + + if (candidates.Count == 0) + { + return; + } + + var dedupKeys = candidates.Select(c => c.DedupKey).Distinct().ToList(); + var existingKeys = await db.Notifications.AsNoTracking() + .Where(n => dedupKeys.Contains(n.DedupKey)) + .Select(n => n.DedupKey) + .ToListAsync(stoppingToken); + + var existingSet = new HashSet(existingKeys); + 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); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao gerar notificações de vigência."); + } + } + + private static Notification BuildNotification( + string tipo, + string titulo, + string mensagem, + DateTime referenciaData, + int diasParaVencer, + Guid? userId, + string? usuario, + string? cliente, + string? linha, + Guid vigenciaLineId) + { + return new Notification + { + Tipo = tipo, + Titulo = titulo, + Mensagem = mensagem, + Data = DateTime.UtcNow, + ReferenciaData = referenciaData, + DiasParaVencer = diasParaVencer, + Lida = false, + DedupKey = BuildDedupKey(tipo, vigenciaLineId, referenciaData, diasParaVencer, usuario, cliente, linha), + UserId = userId, + Usuario = usuario, + Cliente = cliente, + Linha = linha, + VigenciaLineId = vigenciaLineId + }; + } + + private static string BuildDedupKey( + string tipo, + Guid vigenciaLineId, + DateTime referenciaData, + int diasParaVencer, + string? usuario, + string? cliente, + string? linha) + { + var parts = new[] + { + tipo.Trim().ToLowerInvariant(), + vigenciaLineId.ToString(), + 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}"; + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json index 0c208ae..233e874 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Notifications": { + "CheckIntervalMinutes": 60, + "ReminderDays": [30, 15, 7] } } diff --git a/appsettings.json b/appsettings.json index 823e51b..429939a 100644 --- a/appsettings.json +++ b/appsettings.json @@ -7,5 +7,9 @@ "Issuer": "LineGestao", "Audience": "LineGestao", "ExpiresMinutes": 120 + }, + "Notifications": { + "CheckIntervalMinutes": 60, + "ReminderDays": [30, 15, 7] } }