From dc0f3e4c9d8bcbc8ccff9f61b3c4246a7605f4a0 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:42:29 -0300 Subject: [PATCH] Add data consistency sync and gap reporting --- Controllers/AuthController.cs | 39 ++++++++-- Controllers/ConsistencyController.cs | 101 +++++++++++++++++++++++++ Controllers/LinesController.cs | 7 +- Controllers/TrocaNumeroController.cs | 8 +- Controllers/UsersController.cs | 106 ++++++++++++++++++++++++++- Dtos/ConsistencyDtos.cs | 17 +++++ Dtos/LoginRequest.cs | 2 +- Dtos/UserDtos.cs | 4 + Program.cs | 1 + Services/DataConsistencyService.cs | 96 ++++++++++++++++++++++++ Services/IDataConsistencyService.cs | 9 +++ Services/TenantMiddleware.cs | 5 ++ 12 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 Controllers/ConsistencyController.cs create mode 100644 Dtos/ConsistencyDtos.cs create mode 100644 Services/DataConsistencyService.cs create mode 100644 Services/IDataConsistencyService.cs diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index a4d2dc9..c5b3269 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -72,16 +72,34 @@ public class AuthController : ControllerBase [HttpPost("login")] public async Task Login(LoginRequest req) { - // ✅ normaliza e evita null var email = (req.Email ?? "").Trim().ToLowerInvariant(); var password = req.Password ?? ""; var normalizedEmail = _userManager.NormalizeEmail(email); - // ✅ SOLUÇÃO A: ignora filtros globais (multi-tenant / HasQueryFilter) - // e pega 1 usuário (pra você logar logo). - var user = await _userManager.Users - .IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail); + var tenantId = ResolveTenantId(req); + ApplicationUser? user; + + if (tenantId == null) + { + var users = await _userManager.Users + .IgnoreQueryFilters() + .Where(u => u.NormalizedEmail == normalizedEmail) + .ToListAsync(); + + if (users.Count == 0) + return Unauthorized("Credenciais inválidas."); + + if (users.Count > 1) + return BadRequest("Informe o tenant para realizar o login."); + + user = users[0]; + } + else + { + user = await _userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenantId); + } if (user == null) return Unauthorized("Credenciais inválidas."); @@ -97,6 +115,15 @@ public class AuthController : ControllerBase return Ok(new AuthResponse(token)); } + private Guid? ResolveTenantId(LoginRequest req) + { + if (req.TenantId.HasValue) + return req.TenantId.Value; + + var headerValue = Request.Headers["X-Tenant-Id"].FirstOrDefault(); + return Guid.TryParse(headerValue, out var parsed) ? parsed : null; + } + private async Task GenerateJwtAsync(ApplicationUser user) { var key = _config["Jwt:Key"]!; diff --git a/Controllers/ConsistencyController.cs b/Controllers/ConsistencyController.cs new file mode 100644 index 0000000..13a311a --- /dev/null +++ b/Controllers/ConsistencyController.cs @@ -0,0 +1,101 @@ +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/consistency")] +[Authorize(Roles = "admin")] +public class ConsistencyController : ControllerBase +{ + private readonly AppDbContext _db; + + public ConsistencyController(AppDbContext db) + { + _db = db; + } + + [HttpGet("gaps")] + public async Task> GetGaps() + { + var totalLinhas = await _db.MobileLines.AsNoTracking().CountAsync(); + var totalClientes = await _db.MobileLines.AsNoTracking() + .Where(x => x.Cliente != null && x.Cliente != "") + .Select(x => x.Cliente!) + .Distinct() + .CountAsync(); + + var muregLinhas = await _db.MuregLines.AsNoTracking() + .Select(x => x.MobileLineId) + .Distinct() + .CountAsync(); + + var trocaLinhas = await _db.TrocaNumeroLines.AsNoTracking() + .Select(x => x.Item) + .Distinct() + .CountAsync(); + + var userDataLinhas = await _db.UserDatas.AsNoTracking() + .Select(x => x.Item) + .Distinct() + .CountAsync(); + + var vigenciaLinhas = await _db.VigenciaLines.AsNoTracking() + .Select(x => x.Item) + .Distinct() + .CountAsync(); + + var billingClientes = await _db.BillingClients.AsNoTracking() + .Where(x => x.Cliente != null && x.Cliente != "") + .Select(x => x.Cliente) + .Distinct() + .CountAsync(); + + var report = new ConsistencyReportDto + { + TotalLinhasGeral = totalLinhas, + TotalClientesGeral = totalClientes, + LinhasPorAba = new List + { + new() + { + Nome = "Mureg", + TotalGeral = totalLinhas, + TotalAtual = muregLinhas + }, + new() + { + Nome = "TrocaNumero", + TotalGeral = totalLinhas, + TotalAtual = trocaLinhas + }, + new() + { + Nome = "UserData", + TotalGeral = totalLinhas, + TotalAtual = userDataLinhas + }, + new() + { + Nome = "Vigencia", + TotalGeral = totalLinhas, + TotalAtual = vigenciaLinhas + } + }, + ClientesPorAba = new List + { + new() + { + Nome = "Faturamento", + TotalGeral = totalClientes, + TotalAtual = billingClientes + } + } + }; + + return Ok(report); + } +} diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 7c4b97d..8e65d87 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -2,6 +2,7 @@ using line_gestao_api.Data; using line_gestao_api.Dtos; using line_gestao_api.Models; +using line_gestao_api.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -20,10 +21,12 @@ namespace line_gestao_api.Controllers public class LinesController : ControllerBase { private readonly AppDbContext _db; + private readonly IDataConsistencyService _consistencyService; - public LinesController(AppDbContext db) + public LinesController(AppDbContext db, IDataConsistencyService consistencyService) { _db = db; + _consistencyService = consistencyService; } public class ImportExcelForm @@ -480,6 +483,8 @@ namespace line_gestao_api.Controllers try { await _db.SaveChangesAsync(); } catch (DbUpdateException) { return Conflict(new { message = "Conflito ao salvar." }); } + await _consistencyService.SyncFromMobileLineAsync(x); + return NoContent(); } diff --git a/Controllers/TrocaNumeroController.cs b/Controllers/TrocaNumeroController.cs index 7edbf7c..1cf26f3 100644 --- a/Controllers/TrocaNumeroController.cs +++ b/Controllers/TrocaNumeroController.cs @@ -1,6 +1,7 @@ using line_gestao_api.Data; using line_gestao_api.Dtos; using line_gestao_api.Models; +using line_gestao_api.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Globalization; @@ -13,10 +14,12 @@ namespace line_gestao_api.Controllers public class TrocaNumeroController : ControllerBase { private readonly AppDbContext _db; + private readonly IDataConsistencyService _consistencyService; - public TrocaNumeroController(AppDbContext db) + public TrocaNumeroController(AppDbContext db, IDataConsistencyService consistencyService) { _db = db; + _consistencyService = consistencyService; } // ========================================================== @@ -124,6 +127,8 @@ namespace line_gestao_api.Controllers _db.TrocaNumeroLines.Add(e); await _db.SaveChangesAsync(); + await _consistencyService.SyncFromTrocaNumeroAsync(e); + return CreatedAtAction(nameof(GetById), new { id = e.Id }, ToDetailDto(e)); } @@ -149,6 +154,7 @@ namespace line_gestao_api.Controllers x.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _consistencyService.SyncFromTrocaNumeroAsync(x); return NoContent(); } diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs index ee908e2..3af3ce0 100644 --- a/Controllers/UsersController.cs +++ b/Controllers/UsersController.cs @@ -14,6 +14,12 @@ namespace line_gestao_api.Controllers; [Authorize] public class UsersController : ControllerBase { + private static readonly HashSet AllowedRoles = new(StringComparer.OrdinalIgnoreCase) + { + "admin", + "gestor" + }; + private readonly AppDbContext _db; private readonly UserManager _userManager; private readonly RoleManager> _roleManager; @@ -65,7 +71,7 @@ public class UsersController : ControllerBase } var role = req.Permissao.Trim().ToLowerInvariant(); - if (!await _roleManager.RoleExistsAsync(role)) + if (!AllowedRoles.Contains(role) || !await _roleManager.RoleExistsAsync(role)) { return BadRequest(new ValidationErrorResponse { @@ -211,12 +217,33 @@ public class UsersController : ControllerBase [Authorize(Roles = "admin")] public async Task Update(Guid id, [FromBody] UserUpdateRequest req) { + var errors = await ValidateUpdateAsync(id, req); + if (errors.Count > 0) + { + return BadRequest(new ValidationErrorResponse { Errors = errors }); + } + var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id); if (user == null) { return NotFound(); } + if (!string.IsNullOrWhiteSpace(req.Nome)) + { + user.Name = req.Nome.Trim(); + } + + if (!string.IsNullOrWhiteSpace(req.Email)) + { + var email = req.Email.Trim().ToLowerInvariant(); + if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + { + await _userManager.SetEmailAsync(user, email); + await _userManager.SetUserNameAsync(user, email); + } + } + if (req.Ativo.HasValue) { user.IsActive = req.Ativo.Value; @@ -225,7 +252,7 @@ public class UsersController : ControllerBase if (!string.IsNullOrWhiteSpace(req.Permissao)) { var roleName = req.Permissao.Trim().ToLowerInvariant(); - if (!await _roleManager.RoleExistsAsync(roleName)) + if (!AllowedRoles.Contains(roleName) || !await _roleManager.RoleExistsAsync(roleName)) { return BadRequest(new ValidationErrorResponse { @@ -245,6 +272,23 @@ public class UsersController : ControllerBase await _userManager.AddToRoleAsync(user, roleName); } + if (!string.IsNullOrWhiteSpace(req.Senha)) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var resetResult = await _userManager.ResetPasswordAsync(user, token, req.Senha); + if (!resetResult.Succeeded) + { + return BadRequest(new ValidationErrorResponse + { + Errors = resetResult.Errors.Select(e => new ValidationErrorDto + { + Field = "senha", + Message = e.Description + }).ToList() + }); + } + } + await _userManager.UpdateAsync(user); return NoContent(); } @@ -277,6 +321,64 @@ public class UsersController : ControllerBase { errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão é obrigatória." }); } + else if (!AllowedRoles.Contains(req.Permissao.Trim())) + { + errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão inválida." }); + } + + return errors; + } + + private async Task> ValidateUpdateAsync(Guid userId, UserUpdateRequest req) + { + var errors = new List(); + + if (!string.IsNullOrWhiteSpace(req.Nome) && req.Nome.Trim().Length < 2) + { + errors.Add(new ValidationErrorDto { Field = "nome", Message = "Nome inválido." }); + } + + if (!string.IsNullOrWhiteSpace(req.Email)) + { + var email = req.Email.Trim().ToLowerInvariant(); + var normalized = _userManager.NormalizeEmail(email); + + var tenantId = _tenantProvider.TenantId; + if (tenantId == null) + { + errors.Add(new ValidationErrorDto { Field = "email", Message = "Tenant inválido." }); + } + else + { + var exists = await _userManager.Users.AnyAsync(u => + u.Id != userId && + u.TenantId == tenantId && + u.NormalizedEmail == normalized); + + if (exists) + { + errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." }); + } + } + } + + if (!string.IsNullOrWhiteSpace(req.Senha)) + { + if (req.Senha.Length < 6) + { + errors.Add(new ValidationErrorDto { Field = "senha", Message = "Senha deve ter pelo menos 6 caracteres." }); + } + + if (req.Senha != req.ConfirmarSenha) + { + errors.Add(new ValidationErrorDto { Field = "confirmarSenha", Message = "Confirmação de senha inválida." }); + } + } + + if (!string.IsNullOrWhiteSpace(req.Permissao) && !AllowedRoles.Contains(req.Permissao.Trim())) + { + errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão inválida." }); + } return errors; } diff --git a/Dtos/ConsistencyDtos.cs b/Dtos/ConsistencyDtos.cs new file mode 100644 index 0000000..3999155 --- /dev/null +++ b/Dtos/ConsistencyDtos.cs @@ -0,0 +1,17 @@ +namespace line_gestao_api.Dtos; + +public class ConsistencyGapDto +{ + public string Nome { get; set; } = string.Empty; + public int TotalGeral { get; set; } + public int TotalAtual { get; set; } + public int Faltando => Math.Max(0, TotalGeral - TotalAtual); +} + +public class ConsistencyReportDto +{ + public int TotalLinhasGeral { get; set; } + public int TotalClientesGeral { get; set; } + public List LinhasPorAba { get; set; } = new(); + public List ClientesPorAba { get; set; } = new(); +} diff --git a/Dtos/LoginRequest.cs b/Dtos/LoginRequest.cs index cd9cb75..1c48a61 100644 --- a/Dtos/LoginRequest.cs +++ b/Dtos/LoginRequest.cs @@ -1,3 +1,3 @@ namespace line_gestao_api.Dtos; -public record LoginRequest(string Email, string Password); +public record LoginRequest(string Email, string Password, Guid? TenantId = null); diff --git a/Dtos/UserDtos.cs b/Dtos/UserDtos.cs index 97edb4d..697fadc 100644 --- a/Dtos/UserDtos.cs +++ b/Dtos/UserDtos.cs @@ -11,6 +11,10 @@ public class UserCreateRequest public class UserUpdateRequest { + public string? Nome { get; set; } + public string? Email { get; set; } + public string? Senha { get; set; } + public string? ConfirmarSenha { get; set; } public string? Permissao { get; set; } public bool? Ativo { get; set; } } diff --git a/Program.cs b/Program.cs index 6376507..2d33630 100644 --- a/Program.cs +++ b/Program.cs @@ -35,6 +35,7 @@ builder.Services.AddDbContext(options => builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => { diff --git a/Services/DataConsistencyService.cs b/Services/DataConsistencyService.cs new file mode 100644 index 0000000..87e16e4 --- /dev/null +++ b/Services/DataConsistencyService.cs @@ -0,0 +1,96 @@ +using line_gestao_api.Data; +using line_gestao_api.Models; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Services; + +public class DataConsistencyService : IDataConsistencyService +{ + private readonly AppDbContext _db; + + public DataConsistencyService(AppDbContext db) + { + _db = db; + } + + public async Task SyncFromMobileLineAsync(MobileLine line, CancellationToken cancellationToken = default) + { + await ApplyMobileLineToSatellitesAsync(line, cancellationToken); + await _db.SaveChangesAsync(cancellationToken); + } + + public async Task SyncFromTrocaNumeroAsync(TrocaNumeroLine trocaNumero, CancellationToken cancellationToken = default) + { + var linhaAntiga = OnlyDigits(trocaNumero.LinhaAntiga); + if (string.IsNullOrWhiteSpace(linhaAntiga)) + { + return; + } + + var mobile = await _db.MobileLines + .FirstOrDefaultAsync(x => x.Linha != null && OnlyDigits(x.Linha) == linhaAntiga, cancellationToken); + + if (mobile == null) + { + return; + } + + var linhaNova = OnlyDigits(trocaNumero.LinhaNova); + if (!string.IsNullOrWhiteSpace(linhaNova)) + { + mobile.Linha = linhaNova; + } + + var iccid = OnlyDigits(trocaNumero.ICCID); + if (!string.IsNullOrWhiteSpace(iccid)) + { + mobile.Chip = iccid; + } + + mobile.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + await ApplyMobileLineToSatellitesAsync(mobile, cancellationToken); + await _db.SaveChangesAsync(cancellationToken); + } + + private static string OnlyDigits(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var chars = value.Where(char.IsDigit).ToArray(); + return new string(chars); + } + + private async Task ApplyMobileLineToSatellitesAsync(MobileLine line, CancellationToken cancellationToken) + { + var item = line.Item; + + var vigencias = await _db.VigenciaLines + .Where(x => x.Item == item) + .ToListAsync(cancellationToken); + + foreach (var vigencia in vigencias) + { + vigencia.Conta = line.Conta; + vigencia.Linha = line.Linha; + vigencia.Cliente = line.Cliente; + vigencia.Usuario = line.Usuario; + vigencia.PlanoContrato = line.PlanoContrato; + vigencia.UpdatedAt = DateTime.UtcNow; + } + + var userDatas = await _db.UserDatas + .Where(x => x.Item == item) + .ToListAsync(cancellationToken); + + foreach (var userData in userDatas) + { + userData.Linha = line.Linha; + userData.Cliente = line.Cliente; + userData.UpdatedAt = DateTime.UtcNow; + } + } +} diff --git a/Services/IDataConsistencyService.cs b/Services/IDataConsistencyService.cs new file mode 100644 index 0000000..65f7b27 --- /dev/null +++ b/Services/IDataConsistencyService.cs @@ -0,0 +1,9 @@ +using line_gestao_api.Models; + +namespace line_gestao_api.Services; + +public interface IDataConsistencyService +{ + Task SyncFromMobileLineAsync(MobileLine line, CancellationToken cancellationToken = default); + Task SyncFromTrocaNumeroAsync(TrocaNumeroLine trocaNumero, CancellationToken cancellationToken = default); +} diff --git a/Services/TenantMiddleware.cs b/Services/TenantMiddleware.cs index 1806992..f978eed 100644 --- a/Services/TenantMiddleware.cs +++ b/Services/TenantMiddleware.cs @@ -16,11 +16,16 @@ public class TenantMiddleware Guid? tenantId = null; var claim = context.User.FindFirst("tenantId")?.Value ?? context.User.FindFirst("tenant")?.Value; + var headerValue = context.Request.Headers["X-Tenant-Id"].FirstOrDefault(); if (Guid.TryParse(claim, out var parsed)) { tenantId = parsed; } + else if (Guid.TryParse(headerValue, out var headerTenant)) + { + tenantId = headerTenant; + } tenantProvider.SetTenantId(tenantId);