From 7a7b5db73e9de0a2a3ae4d002c509cab0998c58d Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 27 Feb 2026 14:55:05 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20Adi=C3=A7=C3=A3o=20Lote=20de=20Linh?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Controllers/BillingController.cs | 9 + Controllers/HistoricoController.cs | 11 +- Controllers/LinesController.cs | 718 +++++++++++++++++- Controllers/NotificationsController.cs | 4 +- Controllers/RelatoriosController.cs | 25 +- Controllers/TemplatesController.cs | 33 + Controllers/UserDataController.cs | 2 + Controllers/VigenciaController.cs | 71 ++ Data/AppDbContext.cs | 1 + Dtos/LinesBatchExcelPreviewDtos.cs | 74 ++ Dtos/VigenciaDtos.cs | 9 + .../20260227120000_AddVigenciaAutoRenewal.cs | 68 ++ Migrations/AppDbContextModelSnapshot.cs | 14 + Models/VigenciaLine.cs | 4 + Program.cs | 1 + Services/GeralSpreadsheetTemplateService.cs | 180 +++++ Services/VigenciaNotificationSyncService.cs | 193 +++++ 17 files changed, 1397 insertions(+), 20 deletions(-) create mode 100644 Controllers/TemplatesController.cs create mode 100644 Dtos/LinesBatchExcelPreviewDtos.cs create mode 100644 Migrations/20260227120000_AddVigenciaAutoRenewal.cs create mode 100644 Services/GeralSpreadsheetTemplateService.cs diff --git a/Controllers/BillingController.cs b/Controllers/BillingController.cs index d0315fc..55d8aac 100644 --- a/Controllers/BillingController.cs +++ b/Controllers/BillingController.cs @@ -43,11 +43,20 @@ namespace line_gestao_api.Controllers var s = search.Trim(); var hasNumberSearch = TryParseSearchDecimal(s, out var searchNumber); var hasIntSearch = int.TryParse(new string(s.Where(char.IsDigit).ToArray()), out var searchInt); + var matchingClientsByLineOrChip = _db.MobileLines.AsNoTracking() + .Where(m => + EF.Functions.ILike(m.Linha ?? "", $"%{s}%") || + EF.Functions.ILike(m.Chip ?? "", $"%{s}%")) + .Where(m => m.Cliente != null && m.Cliente != "") + .Select(m => m.Cliente!) + .Distinct(); + q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Tipo ?? "", $"%{s}%") || EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || EF.Functions.ILike(x.Aparelho ?? "", $"%{s}%") || EF.Functions.ILike(x.FormaPagamento ?? "", $"%{s}%") + || (x.Cliente != null && matchingClientsByLineOrChip.Contains(x.Cliente)) || (hasIntSearch && (x.QtdLinhas ?? 0) == searchInt) || (hasNumberSearch && (((x.FranquiaVivo ?? 0m) == searchNumber) || diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index 101b793..4e37612 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -26,7 +26,7 @@ public class HistoricoController : ControllerBase [FromQuery] string? pageName, [FromQuery] string? action, [FromQuery] string? entity, - [FromQuery] Guid? userId, + [FromQuery] string? user, [FromQuery] string? search, [FromQuery] DateTime? dateFrom, [FromQuery] DateTime? dateTo, @@ -60,15 +60,17 @@ public class HistoricoController : ControllerBase q = q.Where(x => EF.Functions.ILike(x.EntityName, $"%{e}%")); } - if (userId.HasValue) + if (!string.IsNullOrWhiteSpace(user)) { - q = q.Where(x => x.UserId == userId.Value); + var u = user.Trim(); + q = q.Where(x => + EF.Functions.ILike(x.UserName ?? "", $"%{u}%") || + EF.Functions.ILike(x.UserEmail ?? "", $"%{u}%")); } if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); - var hasGuidSearch = Guid.TryParse(s, out var searchGuid); var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => @@ -83,7 +85,6 @@ public class HistoricoController : ControllerBase EF.Functions.ILike(x.RequestMethod ?? "", $"%{s}%") || EF.Functions.ILike(x.IpAddress ?? "", $"%{s}%") || // ChangesJson is stored as jsonb; applying ILIKE directly causes PostgreSQL 42883. - (hasGuidSearch && (x.Id == searchGuid || x.UserId == searchGuid)) || (hasDateSearch && x.OccurredAtUtc >= searchDateStartUtc && x.OccurredAtUtc < searchDateEndUtc)); diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 5ae8b27..fc3c89a 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -98,8 +98,8 @@ namespace line_gestao_api.Controllers reservaFilter = true; query = query.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -225,8 +225,8 @@ namespace line_gestao_api.Controllers reservaFilter = true; query = query.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -461,8 +461,8 @@ namespace line_gestao_api.Controllers reservaFilter = true; q = q.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } else q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -711,6 +711,13 @@ namespace line_gestao_api.Controllers if (exists) return Conflict(new { message = $"A linha {req.Linha} já está cadastrada no sistema." }); + if (!string.IsNullOrWhiteSpace(chipLimpo)) + { + var chipExists = await _db.MobileLines.AsNoTracking().AnyAsync(x => x.Chip == chipLimpo); + if (chipExists) + return Conflict(new { message = $"O Chip (ICCID) {req.Chip} já está cadastrado no sistema." }); + } + var maxItem = await _db.MobileLines.MaxAsync(x => (int?)x.Item) ?? 0; var nextItem = maxItem + 1; @@ -805,6 +812,12 @@ namespace line_gestao_api.Controllers .Distinct(StringComparer.Ordinal) .ToList(); + var requestedChips = requests + .Select(x => OnlyDigits(x?.Chip)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .ToList(); + var existingLinhas = requestedLinhas.Count == 0 ? new HashSet(StringComparer.Ordinal) : (await _db.MobileLines.AsNoTracking() @@ -813,12 +826,21 @@ namespace line_gestao_api.Controllers .ToListAsync()) .ToHashSet(StringComparer.Ordinal); + var existingChips = requestedChips.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await _db.MobileLines.AsNoTracking() + .Where(x => x.Chip != null && requestedChips.Contains(x.Chip)) + .Select(x => x.Chip!) + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + await using var tx = await _db.Database.BeginTransactionAsync(); try { var nextItem = (await _db.MobileLines.MaxAsync(x => (int?)x.Item) ?? 0); var seenBatchLinhas = new HashSet(StringComparer.Ordinal); + var seenBatchChips = new HashSet(StringComparer.Ordinal); var createdLines = new List<(MobileLine line, VigenciaLine? vigencia)>(requests.Count); for (var i = 0; i < requests.Count; i++) @@ -853,6 +875,15 @@ namespace line_gestao_api.Controllers if (existingLinhas.Contains(linhaLimpa)) return Conflict(new { message = $"A linha {entry.Linha} já está cadastrada no sistema (registro #{lineNo})." }); + if (!string.IsNullOrWhiteSpace(chipLimpo)) + { + if (!seenBatchChips.Add(chipLimpo)) + return Conflict(new { message = $"O Chip (ICCID) {entry.Chip} está duplicado dentro do lote (registro #{lineNo})." }); + + if (existingChips.Contains(chipLimpo)) + return Conflict(new { message = $"O Chip (ICCID) {entry.Chip} já está cadastrado no sistema (registro #{lineNo})." }); + } + nextItem++; var planSuggestion = await AutoFillRules.ResolvePlanSuggestionAsync(_db, entry.PlanoContrato); @@ -941,6 +972,281 @@ namespace line_gestao_api.Controllers } } + // ========================================================== + // ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL) + // ========================================================== + [HttpPost("batch/import-preview")] + [Authorize(Roles = "admin,gestor")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(20_000_000)] + public async Task> PreviewBatchImportExcel([FromForm] ImportExcelForm form) + { + var tenantId = _tenantProvider.TenantId; + if (!tenantId.HasValue || tenantId.Value == Guid.Empty) + return Unauthorized("Tenant inválido."); + + var file = form.File; + if (file == null || file.Length == 0) + return BadRequest(new { message = "Arquivo inválido." }); + + try + { + using var stream = file.OpenReadStream(); + using var wb = new XLWorkbook(stream); + var preview = await BuildLinesBatchExcelPreviewAsync(wb, file.FileName); + return Ok(preview); + } + catch (Exception ex) + { + return BadRequest(new { message = $"Falha ao ler planilha: {ex.Message}" }); + } + } + + // ========================================================== + // ✅ 5.3. ATRIBUIR LINHAS DA RESERVA PARA CLIENTE + // ========================================================== + [HttpPost("reserva/assign-client")] + [Authorize(Roles = "admin,gestor")] + public async Task> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req) + { + var ids = (req?.LineIds ?? new List()) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return BadRequest(new { message = "Selecione ao menos uma linha da Reserva." }); + + if (ids.Count > 1000) + return BadRequest(new { message = "Limite de 1000 linhas por atribuição em lote." }); + + var clienteDestino = (req?.ClienteDestino ?? "").Trim(); + if (string.IsNullOrWhiteSpace(clienteDestino)) + return BadRequest(new { message = "Informe o cliente de destino." }); + + if (IsReservaValue(clienteDestino)) + return BadRequest(new { message = "O cliente de destino não pode ser RESERVA." }); + + var usuarioDestino = string.IsNullOrWhiteSpace(req?.UsuarioDestino) ? null : req!.UsuarioDestino!.Trim(); + var skilDestinoSolicitado = string.IsNullOrWhiteSpace(req?.SkilDestino) ? null : req!.SkilDestino!.Trim(); + var clienteDestinoUpper = clienteDestino.ToUpperInvariant(); + + var linhas = await _db.MobileLines + .Where(x => ids.Contains(x.Id)) + .OrderBy(x => x.Item) + .ToListAsync(); + + var byId = linhas.ToDictionary(x => x.Id, x => x); + + var inferSkilDestino = await _db.MobileLines.AsNoTracking() + .Where(x => x.Cliente != null && EF.Functions.ILike(x.Cliente, clienteDestino)) + .Where(x => x.Skil != null && x.Skil != "" && !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA")) + .OrderByDescending(x => x.UpdatedAt) + .Select(x => x.Skil) + .FirstOrDefaultAsync(); + + var result = new AssignReservaLinesResultDto + { + Requested = ids.Count + }; + + foreach (var id in ids) + { + if (!byId.TryGetValue(id, out var line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = id, + Success = false, + Message = "Linha não encontrada." + }); + result.Failed++; + continue; + } + + if (!IsReservaLineForTransfer(line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = line.Cliente, + ClienteNovo = line.Cliente, + Success = false, + Message = "A linha não está apta para transferência (não está na Reserva)." + }); + result.Failed++; + continue; + } + + var clienteAnterior = line.Cliente; + line.Cliente = clienteDestinoUpper; + + if (IsReservaValue(line.Usuario)) + { + line.Usuario = usuarioDestino; + } + else if (!string.IsNullOrWhiteSpace(usuarioDestino)) + { + line.Usuario = usuarioDestino; + } + + var skilDestinoEfetivo = skilDestinoSolicitado; + if (string.IsNullOrWhiteSpace(skilDestinoEfetivo) && IsReservaValue(line.Skil)) + { + skilDestinoEfetivo = inferSkilDestino; + } + + if (!string.IsNullOrWhiteSpace(skilDestinoEfetivo)) + { + line.Skil = skilDestinoEfetivo; + } + else if (IsReservaValue(line.Skil)) + { + line.Skil = null; + } + + line.UpdatedAt = DateTime.UtcNow; + + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = clienteAnterior, + ClienteNovo = line.Cliente, + Success = true, + Message = "Linha atribuída com sucesso." + }); + result.Updated++; + } + + if (result.Updated <= 0) + { + return Ok(result); + } + + try + { + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + result.Failed = result.Requested - result.Updated; + return Ok(result); + } + catch (DbUpdateException) + { + return StatusCode(500, new { message = "Erro ao atribuir linhas da Reserva." }); + } + } + + // ========================================================== + // ✅ 5.4. MOVER LINHAS DE CLIENTE PARA RESERVA + // ========================================================== + [HttpPost("move-to-reserva")] + [Authorize(Roles = "admin,gestor")] + public async Task> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req) + { + var ids = (req?.LineIds ?? new List()) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return BadRequest(new { message = "Selecione ao menos uma linha para enviar à Reserva." }); + + if (ids.Count > 1000) + return BadRequest(new { message = "Limite de 1000 linhas por movimentação em lote." }); + + var linhas = await _db.MobileLines + .Where(x => ids.Contains(x.Id)) + .OrderBy(x => x.Item) + .ToListAsync(); + + var byId = linhas.ToDictionary(x => x.Id, x => x); + var result = new AssignReservaLinesResultDto + { + Requested = ids.Count + }; + + foreach (var id in ids) + { + if (!byId.TryGetValue(id, out var line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = id, + Success = false, + Message = "Linha não encontrada." + }); + result.Failed++; + continue; + } + + if (IsReservaLineForTransfer(line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = line.Cliente, + ClienteNovo = line.Cliente, + Success = false, + Message = "A linha já está disponível na Reserva." + }); + result.Failed++; + continue; + } + + var clienteAnterior = line.Cliente; + var clienteOrigemNormalizado = string.IsNullOrWhiteSpace(clienteAnterior) + ? null + : clienteAnterior.Trim(); + + // Mantém o cliente de origem para que o agrupamento no filtro "Reserva" + // continue exibindo o cliente correto após o envio. + line.Cliente = string.IsNullOrWhiteSpace(clienteOrigemNormalizado) + ? "RESERVA" + : clienteOrigemNormalizado; + line.Usuario = "RESERVA"; + line.Skil = "RESERVA"; + ApplyReservaRule(line); + line.UpdatedAt = DateTime.UtcNow; + + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = clienteAnterior, + ClienteNovo = "RESERVA", + Success = true, + Message = "Linha enviada para a Reserva com sucesso." + }); + result.Updated++; + } + + if (result.Updated <= 0) + return Ok(result); + + try + { + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + result.Failed = result.Requested - result.Updated; + return Ok(result); + } + catch (DbUpdateException) + { + return StatusCode(500, new { message = "Erro ao mover linhas para a Reserva." }); + } + } + // ========================================================== // ✅ 6. UPDATE // ========================================================== @@ -951,6 +1257,7 @@ namespace line_gestao_api.Controllers var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); var previousLinha = x.Linha; + var previousCliente = x.Cliente; var newLinha = OnlyDigits(req.Linha); if (!string.IsNullOrWhiteSpace(newLinha) && !string.Equals((x.Linha ?? ""), newLinha, StringComparison.Ordinal)) @@ -994,6 +1301,26 @@ namespace line_gestao_api.Controllers x.VencConta = req.VencConta?.Trim(); x.TipoDeChip = req.TipoDeChip?.Trim(); + var previousClienteNormalized = string.IsNullOrWhiteSpace(previousCliente) ? null : previousCliente.Trim(); + var clienteAtualIsReserva = IsReservaValue(x.Cliente); + var usuarioAtualIsReserva = IsReservaValue(x.Usuario); + var skilAtualIsReserva = IsReservaValue(x.Skil); + var reserveContextRequested = clienteAtualIsReserva || usuarioAtualIsReserva || skilAtualIsReserva; + + // Quando uma linha é enviada para Reserva por edição, preserva o cliente de origem para + // o agrupamento no filtro "Reservas" (desde que o cliente anterior não fosse RESERVA). + if (reserveContextRequested && (string.IsNullOrWhiteSpace(x.Cliente) || clienteAtualIsReserva)) + { + if (!string.IsNullOrWhiteSpace(previousClienteNormalized) && !IsReservaValue(previousClienteNormalized)) + { + x.Cliente = previousClienteNormalized; + } + else if (string.IsNullOrWhiteSpace(x.Cliente)) + { + x.Cliente = "RESERVA"; + } + } + ApplyReservaRule(x); await UpsertVigenciaFromMobileLineAsync( @@ -3331,6 +3658,385 @@ namespace line_gestao_api.Controllers return hasItem && hasNumeroChip && hasObs; } + private async Task BuildLinesBatchExcelPreviewAsync(XLWorkbook wb, string? fileName) + { + var result = new LinesBatchExcelPreviewResultDto + { + FileName = fileName + }; + + static LinesBatchExcelIssueDto Issue(string? column, string message) => new() + { + Column = column, + Message = message + }; + + static string? NullIfWhite(string? v) + => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); + + static bool LooksLikeBatchGeralHeader(IXLRow row) + { + if (row == null) return false; + + var map = BuildHeaderMap(row); + if (map.Count == 0) return false; + + var hits = 0; + if (GetColAny(map, "CONTA") > 0) hits++; + if (GetColAny(map, "LINHA") > 0) hits++; + if (GetColAny(map, "CHIP") > 0) hits++; + if (GetColAny(map, "CLIENTE") > 0) hits++; + if (GetColAny(map, "USUARIO") > 0) hits++; + if (GetColAny(map, "PLANO CONTRATO") > 0) hits++; + if (GetColAny(map, "STATUS") > 0) hits++; + if (GetColAny(map, "TIPO DE CHIP", "TIPO CHIP") > 0) hits++; + + // Assinatura mínima para reconhecer uma planilha nova com cabeçalhos da GERAL + return hits >= 6; + } + + var ws = wb.Worksheets.FirstOrDefault(w => w.Name.Trim().Equals("GERAL", StringComparison.OrdinalIgnoreCase)); + IXLRow? headerRow = null; + var usedFallbackSheet = false; + + if (ws != null) + { + headerRow = ws.RowsUsed() + .Take(100) + .FirstOrDefault(LooksLikeBatchGeralHeader); + } + + if (ws == null || headerRow == null || !LooksLikeBatchGeralHeader(headerRow)) + { + foreach (var candidateWs in wb.Worksheets) + { + var candidateHeader = candidateWs.RowsUsed() + .Take(100) + .FirstOrDefault(LooksLikeBatchGeralHeader); + + if (candidateHeader == null) continue; + + ws = candidateWs; + headerRow = candidateHeader; + usedFallbackSheet = true; + break; + } + } + + if (ws == null || headerRow == null) + { + result.HeaderErrors.Add(Issue( + "ABA/CABEÇALHO", + "Nenhuma aba compatível encontrada. A planilha deve conter os cabeçalhos da GERAL (ex.: CONTA, LINHA, CHIP, CLIENTE...).")); + result.CanProceed = false; + return result; + } + + result.SheetName = ws.Name; + + if (usedFallbackSheet && !ws.Name.Trim().Equals("GERAL", StringComparison.OrdinalIgnoreCase)) + { + result.HeaderWarnings.Add(Issue( + "ABA", + $"Aba 'GERAL' não foi encontrada. Usando a aba '{ws.Name}' por conter cabeçalhos compatíveis.")); + } + + var map = BuildHeaderMap(headerRow); + + var expectedHeaders = new List<(string Display, bool Required, string[] Aliases)> + { + ("CONTA", true, new[] { "CONTA" }), + ("LINHA", true, new[] { "LINHA" }), + ("CHIP", true, new[] { "CHIP" }), + ("CLIENTE", true, new[] { "CLIENTE" }), + ("USUÁRIO", true, new[] { "USUARIO" }), + ("PLANO CONTRATO", true, new[] { "PLANO CONTRATO" }), + ("FRAQUIA", true, new[] { "FRAQUIA", "FRANQUIA", "FRANQUIA VIVO", "FRAQUIA VIVO" }), + ("VALOR DO PLANO R$", true, new[] { "VALOR DO PLANO R$", "VALOR DO PLANO", "VALORPLANO" }), + ("GESTÃO VOZ E DADOS R$", true, new[] { "GESTAO VOZ E DADOS R$", "GESTAO VOZ E DADOS", "GESTAOVOZEDADOS" }), + ("SKEELO", true, new[] { "SKEELO" }), + ("VIVO NEWS PLUS", true, new[] { "VIVO NEWS PLUS" }), + ("VIVO TRAVEL MUNDO", true, new[] { "VIVO TRAVEL MUNDO" }), + ("VIVO SYNC", true, new[] { "VIVO SYNC" }), + ("VIVO GESTÃO DISPOSITIVO", true, new[] { "VIVO GESTAO DISPOSITIVO" }), + ("VALOR CONTRATO VIVO", true, new[] { "VALOR CONTRATO VIVO", "VALOR DO CONTRATO VIVO" }), + ("FRANQUIA LINE", true, new[] { "FRANQUIA LINE", "FRAQUIA LINE" }), + ("FRANQUIA GESTÃO", true, new[] { "FRANQUIA GESTAO", "FRAQUIA GESTAO" }), + ("LOCAÇÃO AP.", true, new[] { "LOCACAO AP.", "LOCACAO AP", "LOCACAOAP" }), + ("VALOR CONTRATO LINE", true, new[] { "VALOR CONTRATO LINE", "VALOR DO CONTRATO LINE" }), + ("DESCONTO", true, new[] { "DESCONTO" }), + ("LUCRO", true, new[] { "LUCRO" }), + ("STATUS", true, new[] { "STATUS" }), + ("DATA DO BLOQUEIO", true, new[] { "DATA DO BLOQUEIO" }), + ("SKIL", true, new[] { "SKIL" }), + ("MODALIDADE", true, new[] { "MODALIDADE" }), + ("CEDENTE", true, new[] { "CEDENTE" }), + ("SOLICITANTE", true, new[] { "SOLICITANTE" }), + ("DATA DA ENTREGA OPERA.", true, new[] { "DATA DA ENTREGA OPERA." }), + ("DATA DA ENTREGA CLIENTE", true, new[] { "DATA DA ENTREGA CLIENTE" }), + ("VENC. DA CONTA", true, new[] { "VENC. DA CONTA", "VENC DA CONTA", "VENCIMENTO DA CONTA" }), + ("TIPO DE CHIP", true, new[] { "TIPO DE CHIP", "TIPO CHIP" }) + }; + + foreach (var spec in expectedHeaders) + { + if (GetColAny(map, spec.Aliases) > 0) continue; + var issue = Issue(spec.Display, $"Coluna obrigatória ausente: '{spec.Display}'."); + if (spec.Required) result.HeaderErrors.Add(issue); + else result.HeaderWarnings.Add(issue); + } + + var colDtEfetivacao = GetColAny(map, + "DT EFETIVACAO SERVICO", + "DT. EFETIVACAO SERVICO", + "DT. EFETIVAÇÃO SERVIÇO", + "DT EFETIVAÇÃO SERVIÇO"); + var colDtTermino = GetColAny(map, + "DT TERMINO FIDELIZACAO", + "DT. TERMINO FIDELIZACAO", + "DT. TÉRMINO FIDELIZAÇÃO", + "DT TÉRMINO FIDELIZAÇÃO"); + + if (colDtEfetivacao == 0 || colDtTermino == 0) + { + result.HeaderWarnings.Add(Issue( + "DT. EFETIVAÇÃO / DT. TÉRMINO", + "As colunas de vigência não foram encontradas na aba GERAL. Elas continuam obrigatórias para salvar o lote e podem ser preenchidas no modal (detalhes/parâmetros padrão).")); + } + + var expectedCols = expectedHeaders + .Select(x => GetColAny(map, x.Aliases)) + .Where(c => c > 0) + .Distinct() + .ToList(); + if (colDtEfetivacao > 0) expectedCols.Add(colDtEfetivacao); + if (colDtTermino > 0) expectedCols.Add(colDtTermino); + + var startRow = headerRow.RowNumber() + 1; + var lastRow = ws.LastRowUsed()?.RowNumber() ?? startRow; + var rows = new List(); + + for (var r = startRow; r <= lastRow; r++) + { + var rowHasAnyValue = expectedCols.Any(c => !string.IsNullOrWhiteSpace(GetCellString(ws, r, c))); + if (!rowHasAnyValue) + { + if (rows.Count > 0) break; + continue; + } + + var rowDto = new LinesBatchExcelPreviewRowDto + { + SourceRowNumber = r, + Data = new CreateMobileLineDto { Item = 0 } + }; + + void AddRowError(string? column, string message) => rowDto.Errors.Add(Issue(column, message)); + void AddRowWarning(string? column, string message) => rowDto.Warnings.Add(Issue(column, message)); + + string CellBy(params string[] headers) => GetCellByHeaderAny(ws, r, map, headers); + + decimal? ParseDecimalField(string columnDisplay, params string[] headers) + { + var raw = CellBy(headers); + if (string.IsNullOrWhiteSpace(raw)) return null; + var parsed = TryDecimal(raw); + if (!parsed.HasValue) + AddRowError(columnDisplay, $"Valor numérico inválido: '{raw}'."); + return parsed; + } + + DateTime? ParseDateField(string columnDisplay, params string[] headers) + { + var col = GetColAny(map, headers); + if (col <= 0) return null; + var raw = GetCellString(ws, r, col); + if (string.IsNullOrWhiteSpace(raw) && ws.Cell(r, col).IsEmpty()) return null; + var parsed = TryDateCell(ws, r, col); + if (!parsed.HasValue && !string.IsNullOrWhiteSpace(raw)) + AddRowError(columnDisplay, $"Data inválida: '{raw}'."); + return parsed; + } + + var itemRaw = CellBy("ITEM"); + if (!string.IsNullOrWhiteSpace(itemRaw)) + { + var parsedItem = TryNullableInt(itemRaw); + rowDto.SourceItem = parsedItem; + AddRowWarning("ITÉM", "Valor informado será ignorado. O sistema gera a sequência automaticamente."); + } + + var linhaRaw = CellBy("LINHA"); + var linhaDigits = OnlyDigits(linhaRaw); + if (string.IsNullOrWhiteSpace(linhaRaw)) + AddRowError("LINHA", "Campo obrigatório."); + else if (string.IsNullOrWhiteSpace(linhaDigits)) + AddRowError("LINHA", $"Valor inválido: '{linhaRaw}'."); + rowDto.Data.Linha = string.IsNullOrWhiteSpace(linhaDigits) ? NullIfWhite(linhaRaw) : linhaDigits; + + var chipRaw = CellBy("CHIP"); + var chipDigits = OnlyDigits(chipRaw); + if (string.IsNullOrWhiteSpace(chipRaw)) + AddRowError("CHIP", "Campo obrigatório."); + else if (string.IsNullOrWhiteSpace(chipDigits)) + AddRowError("CHIP", $"Valor inválido: '{chipRaw}'."); + rowDto.Data.Chip = string.IsNullOrWhiteSpace(chipDigits) ? NullIfWhite(chipRaw) : chipDigits; + + rowDto.Data.Conta = NullIfWhite(CellBy("CONTA")); + if (string.IsNullOrWhiteSpace(rowDto.Data.Conta)) + AddRowError("CONTA", "Campo obrigatório."); + + rowDto.Data.Cliente = NullIfWhite(CellBy("CLIENTE")); + rowDto.Data.Usuario = NullIfWhite(CellBy("USUARIO")); + rowDto.Data.PlanoContrato = NullIfWhite(CellBy("PLANO CONTRATO")); + if (string.IsNullOrWhiteSpace(rowDto.Data.PlanoContrato)) + AddRowError("PLANO CONTRATO", "Campo obrigatório."); + + rowDto.Data.Status = NullIfWhite(CellBy("STATUS")); + if (string.IsNullOrWhiteSpace(rowDto.Data.Status)) + AddRowError("STATUS", "Campo obrigatório."); + + rowDto.Data.Skil = NullIfWhite(CellBy("SKIL")); + rowDto.Data.Modalidade = NullIfWhite(CellBy("MODALIDADE")); + rowDto.Data.Cedente = NullIfWhite(CellBy("CEDENTE")); + rowDto.Data.Solicitante = NullIfWhite(CellBy("SOLICITANTE")); + rowDto.Data.VencConta = NullIfWhite(CellBy("VENC. DA CONTA", "VENC DA CONTA", "VENCIMENTO DA CONTA")); + rowDto.Data.TipoDeChip = NullIfWhite(CellBy("TIPO DE CHIP", "TIPO CHIP")); + + rowDto.Data.FranquiaVivo = ParseDecimalField("FRAQUIA", "FRAQUIA", "FRANQUIA", "FRANQUIA VIVO", "FRAQUIA VIVO"); + rowDto.Data.ValorPlanoVivo = ParseDecimalField("VALOR DO PLANO R$", "VALOR DO PLANO R$", "VALOR DO PLANO", "VALORPLANO"); + rowDto.Data.GestaoVozDados = ParseDecimalField("GESTÃO VOZ E DADOS R$", "GESTAO VOZ E DADOS R$", "GESTAO VOZ E DADOS", "GESTAOVOZEDADOS"); + rowDto.Data.Skeelo = ParseDecimalField("SKEELO", "SKEELO"); + rowDto.Data.VivoNewsPlus = ParseDecimalField("VIVO NEWS PLUS", "VIVO NEWS PLUS"); + rowDto.Data.VivoTravelMundo = ParseDecimalField("VIVO TRAVEL MUNDO", "VIVO TRAVEL MUNDO"); + rowDto.Data.VivoSync = ParseDecimalField("VIVO SYNC", "VIVO SYNC"); + rowDto.Data.VivoGestaoDispositivo = ParseDecimalField("VIVO GESTÃO DISPOSITIVO", "VIVO GESTAO DISPOSITIVO"); + rowDto.Data.ValorContratoVivo = ParseDecimalField("VALOR CONTRATO VIVO", "VALOR CONTRATO VIVO", "VALOR DO CONTRATO VIVO"); + rowDto.Data.FranquiaLine = ParseDecimalField("FRANQUIA LINE", "FRANQUIA LINE", "FRAQUIA LINE"); + rowDto.Data.FranquiaGestao = ParseDecimalField("FRANQUIA GESTÃO", "FRANQUIA GESTAO", "FRAQUIA GESTAO"); + rowDto.Data.LocacaoAp = ParseDecimalField("LOCAÇÃO AP.", "LOCACAO AP.", "LOCACAO AP", "LOCACAOAP"); + rowDto.Data.ValorContratoLine = ParseDecimalField("VALOR CONTRATO LINE", "VALOR CONTRATO LINE", "VALOR DO CONTRATO LINE"); + rowDto.Data.Desconto = ParseDecimalField("DESCONTO", "DESCONTO"); + rowDto.Data.Lucro = ParseDecimalField("LUCRO", "LUCRO"); + + rowDto.Data.DataBloqueio = ParseDateField("DATA DO BLOQUEIO", "DATA DO BLOQUEIO"); + rowDto.Data.DataEntregaOpera = ParseDateField("DATA DA ENTREGA OPERA.", "DATA DA ENTREGA OPERA."); + rowDto.Data.DataEntregaCliente = ParseDateField("DATA DA ENTREGA CLIENTE", "DATA DA ENTREGA CLIENTE"); + rowDto.Data.DtEfetivacaoServico = colDtEfetivacao > 0 ? ParseDateField("DT. EFETIVAÇÃO SERVIÇO", + "DT EFETIVACAO SERVICO", "DT. EFETIVACAO SERVICO", "DT. EFETIVAÇÃO SERVIÇO", "DT EFETIVAÇÃO SERVIÇO") : null; + rowDto.Data.DtTerminoFidelizacao = colDtTermino > 0 ? ParseDateField("DT. TÉRMINO FIDELIZAÇÃO", + "DT TERMINO FIDELIZACAO", "DT. TERMINO FIDELIZACAO", "DT. TÉRMINO FIDELIZAÇÃO", "DT TÉRMINO FIDELIZAÇÃO") : null; + + if (colDtEfetivacao == 0 || !rowDto.Data.DtEfetivacaoServico.HasValue) + AddRowWarning("DT. EFETIVAÇÃO SERVIÇO", "Campo não preenchido pela aba GERAL; complete no modal antes de salvar."); + if (colDtTermino == 0 || !rowDto.Data.DtTerminoFidelizacao.HasValue) + AddRowWarning("DT. TÉRMINO FIDELIZAÇÃO", "Campo não preenchido pela aba GERAL; complete no modal antes de salvar."); + + rows.Add(rowDto); + } + + var linhasArquivo = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Linha)) + .GroupBy(x => x.Data.Linha!, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + var chipsArquivo = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Chip)) + .GroupBy(x => x.Data.Chip!, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + var linhasParaConsultar = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Linha)) + .Select(x => x.Data.Linha!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var chipsParaConsultar = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Chip)) + .Select(x => x.Data.Chip!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var linhasExistentes = linhasParaConsultar.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await _db.MobileLines.AsNoTracking() + .Where(x => x.Linha != null && linhasParaConsultar.Contains(x.Linha)) + .Select(x => x.Linha!) + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + + var chipsExistentes = chipsParaConsultar.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await _db.MobileLines.AsNoTracking() + .Where(x => x.Chip != null && chipsParaConsultar.Contains(x.Chip)) + .Select(x => x.Chip!) + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + + var maxItem = await _db.MobileLines.AsNoTracking().MaxAsync(x => (int?)x.Item) ?? 0; + result.NextItemStart = maxItem + 1; + var previewNextItem = maxItem; + + foreach (var row in rows) + { + var linha = row.Data.Linha; + var chip = row.Data.Chip; + + if (!string.IsNullOrWhiteSpace(linha) && linhasArquivo.Contains(linha)) + { + row.DuplicateLinhaInFile = true; + row.Errors.Add(Issue("LINHA", $"Linha duplicada dentro da planilha: {linha}.")); + } + + if (!string.IsNullOrWhiteSpace(chip) && chipsArquivo.Contains(chip)) + { + row.DuplicateChipInFile = true; + row.Errors.Add(Issue("CHIP", $"Chip (ICCID) duplicado dentro da planilha: {chip}.")); + } + + if (!string.IsNullOrWhiteSpace(linha) && linhasExistentes.Contains(linha)) + { + row.DuplicateLinhaInSystem = true; + row.Errors.Add(Issue("LINHA", $"Linha já cadastrada no sistema: {linha}.")); + } + + if (!string.IsNullOrWhiteSpace(chip) && chipsExistentes.Contains(chip)) + { + row.DuplicateChipInSystem = true; + row.Errors.Add(Issue("CHIP", $"Chip (ICCID) já cadastrado no sistema: {chip}.")); + } + + row.Valid = row.Errors.Count == 0; + if (row.Valid) + { + previewNextItem++; + row.GeneratedItemPreview = previewNextItem; + } + } + + result.Rows = rows; + result.TotalRows = rows.Count; + result.ValidRows = rows.Count(x => x.Valid); + result.InvalidRows = rows.Count(x => !x.Valid); + result.DuplicateRows = rows.Count(x => + x.DuplicateLinhaInFile || x.DuplicateChipInFile || x.DuplicateLinhaInSystem || x.DuplicateChipInSystem); + result.CanProceed = result.HeaderErrors.Count == 0 && result.ValidRows > 0; + + return result; + } + + private static bool IsReservaLineForTransfer(MobileLine line) + { + if (line == null) return false; + return IsReservaValue(line.Usuario) + || IsReservaValue(line.Skil) + || IsReservaValue(line.Cliente); + } + // ========================================================== // HELPERS (SEUS) // ========================================================== diff --git a/Controllers/NotificationsController.cs b/Controllers/NotificationsController.cs index a9f2317..adffa76 100644 --- a/Controllers/NotificationsController.cs +++ b/Controllers/NotificationsController.cs @@ -45,7 +45,9 @@ public class NotificationsController : ControllerBase DiasParaVencer = notification.DiasParaVencer, Lida = notification.Lida, LidaEm = notification.LidaEm, - VigenciaLineId = notification.VigenciaLineId, + VigenciaLineId = notification.VigenciaLineId + ?? (vigencia != null ? (Guid?)vigencia.Id : null) + ?? (vigenciaByLinha != null ? (Guid?)vigenciaByLinha.Id : null), Cliente = notification.Cliente ?? (vigencia != null ? vigencia.Cliente : null) ?? (vigenciaByLinha != null ? vigenciaByLinha.Cliente : null), diff --git a/Controllers/RelatoriosController.cs b/Controllers/RelatoriosController.cs index bf4827e..f17c415 100644 --- a/Controllers/RelatoriosController.cs +++ b/Controllers/RelatoriosController.cs @@ -32,6 +32,7 @@ namespace line_gestao_api.Controllers // GERAL (MobileLines) // ========================= var qLines = _db.MobileLines.AsNoTracking(); + var qLinesWithClient = qLines.Where(x => x.Cliente != null && x.Cliente != ""); var totalLinhas = await qLines.CountAsync(); @@ -44,27 +45,35 @@ namespace line_gestao_api.Controllers var ativos = await qLines.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%")); - var bloqueadosPerdaRoubo = await qLines.CountAsync(x => + var bloqueadosPerdaRoubo = await qLinesWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")); - var bloqueados120Dias = await qLines.CountAsync(x => + var bloqueados120Dias = await qLinesWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && - EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%")); + EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%") && + !(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || + EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"))); - var bloqueadosOutros = await qLines.CountAsync(x => + var bloqueadosOutros = await qLinesWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") && !(EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%")) && !(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")) ); - var bloqueados = bloqueadosPerdaRoubo + bloqueados120Dias + bloqueadosOutros; + // Regra do KPI "Bloqueadas" alinhada ao critério da página Geral: + // status contendo "bloque", "perda" ou "roubo". + var bloqueados = await qLinesWithClient.CountAsync(x => + EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") || + EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || + EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")); + // Regra alinhada ao filtro "Reservas" da página Geral. var reservas = await qLines.CountAsync(x => - EF.Functions.ILike((x.Cliente ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Usuario ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "%RESERVA%")); + EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); var topClientes = await qLines .Where(x => x.Cliente != null && x.Cliente != "") diff --git a/Controllers/TemplatesController.cs b/Controllers/TemplatesController.cs new file mode 100644 index 0000000..98a7e6a --- /dev/null +++ b/Controllers/TemplatesController.cs @@ -0,0 +1,33 @@ +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace line_gestao_api.Controllers +{ + [ApiController] + [Route("api/templates")] + [Authorize(Roles = "admin,gestor")] + public class TemplatesController : ControllerBase + { + private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService; + + public TemplatesController(GeralSpreadsheetTemplateService geralSpreadsheetTemplateService) + { + _geralSpreadsheetTemplateService = geralSpreadsheetTemplateService; + } + + [HttpGet("planilha-geral")] + public IActionResult DownloadPlanilhaGeral() + { + Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + Response.Headers["Pragma"] = "no-cache"; + Response.Headers["Expires"] = "0"; + + var bytes = _geralSpreadsheetTemplateService.BuildPlanilhaGeralTemplate(); + return File( + bytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "MODELO_GERAL_LINEGESTAO.xlsx"); + } + } +} diff --git a/Controllers/UserDataController.cs b/Controllers/UserDataController.cs index 40ad72c..58dbfc8 100644 --- a/Controllers/UserDataController.cs +++ b/Controllers/UserDataController.cs @@ -56,6 +56,7 @@ namespace line_gestao_api.Controllers var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") || EF.Functions.ILike(x.Nome ?? "", $"%{s}%") || @@ -151,6 +152,7 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") || EF.Functions.ILike(x.Nome ?? "", $"%{s}%") || EF.Functions.ILike(x.RazaoSocial ?? "", $"%{s}%") || diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index 5c1d4ed..4b700e8 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -56,6 +56,7 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") || @@ -98,6 +99,10 @@ namespace line_gestao_api.Controllers PlanoContrato = x.PlanoContrato, DtEfetivacaoServico = x.DtEfetivacaoServico, DtTerminoFidelizacao = x.DtTerminoFidelizacao, + AutoRenewYears = x.AutoRenewYears, + AutoRenewReferenceEndDate = x.AutoRenewReferenceEndDate, + AutoRenewConfiguredAt = x.AutoRenewConfiguredAt, + LastAutoRenewedAt = x.LastAutoRenewedAt, Total = x.Total }) .ToListAsync(); @@ -142,6 +147,7 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") || @@ -239,6 +245,10 @@ namespace line_gestao_api.Controllers PlanoContrato = x.PlanoContrato, DtEfetivacaoServico = x.DtEfetivacaoServico, DtTerminoFidelizacao = x.DtTerminoFidelizacao, + AutoRenewYears = x.AutoRenewYears, + AutoRenewReferenceEndDate = x.AutoRenewReferenceEndDate, + AutoRenewConfiguredAt = x.AutoRenewConfiguredAt, + LastAutoRenewedAt = x.LastAutoRenewedAt, Total = x.Total, CreatedAt = x.CreatedAt, UpdatedAt = x.UpdatedAt @@ -333,6 +343,10 @@ namespace line_gestao_api.Controllers PlanoContrato = e.PlanoContrato, DtEfetivacaoServico = e.DtEfetivacaoServico, DtTerminoFidelizacao = e.DtTerminoFidelizacao, + AutoRenewYears = e.AutoRenewYears, + AutoRenewReferenceEndDate = e.AutoRenewReferenceEndDate, + AutoRenewConfiguredAt = e.AutoRenewConfiguredAt, + LastAutoRenewedAt = e.LastAutoRenewedAt, Total = e.Total, CreatedAt = e.CreatedAt, UpdatedAt = e.UpdatedAt @@ -346,6 +360,9 @@ namespace line_gestao_api.Controllers var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); + var previousEfetivacao = x.DtEfetivacaoServico; + var previousTermino = x.DtTerminoFidelizacao; + if (req.Item.HasValue) x.Item = req.Item.Value; if (req.Conta != null) x.Conta = TrimOrNull(req.Conta); if (req.Linha != null) x.Linha = TrimOrNull(req.Linha); @@ -358,6 +375,13 @@ namespace line_gestao_api.Controllers if (req.Total.HasValue) x.Total = req.Total.Value; + var efetivacaoChanged = req.DtEfetivacaoServico.HasValue && !IsSameUtcDate(previousEfetivacao, x.DtEfetivacaoServico); + var terminoChanged = req.DtTerminoFidelizacao.HasValue && !IsSameUtcDate(previousTermino, x.DtTerminoFidelizacao); + if (efetivacaoChanged || terminoChanged) + { + ClearAutoRenewSchedule(x); + } + x.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); @@ -365,6 +389,39 @@ namespace line_gestao_api.Controllers return NoContent(); } + [HttpPost("{id:guid}/renew")] + public async Task ConfigureAutoRenew(Guid id, [FromBody] ConfigureVigenciaRenewalRequest req) + { + if (req.Years != 2) + { + return BadRequest(new { message = "A renovação automática permite somente 2 anos." }); + } + + var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); + if (x == null) return NotFound(); + + if (!x.DtTerminoFidelizacao.HasValue) + { + return BadRequest(new { message = "A linha não possui data de término de fidelização para programar renovação." }); + } + + var now = DateTime.UtcNow; + x.AutoRenewYears = 2; + x.AutoRenewReferenceEndDate = DateTime.SpecifyKind(x.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc); + x.AutoRenewConfiguredAt = now; + x.UpdatedAt = now; + + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + + return Ok(new + { + message = "Renovação automática de +2 anos programada para o vencimento.", + autoRenewYears = x.AutoRenewYears, + autoRenewReferenceEndDate = x.AutoRenewReferenceEndDate + }); + } + [HttpDelete("{id:guid}")] [Authorize(Roles = "admin")] public async Task Delete(Guid id) @@ -390,6 +447,20 @@ namespace line_gestao_api.Controllers : (dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : DateTime.SpecifyKind(dt, DateTimeKind.Utc)); } + private static void ClearAutoRenewSchedule(VigenciaLine line) + { + line.AutoRenewYears = null; + line.AutoRenewReferenceEndDate = null; + line.AutoRenewConfiguredAt = null; + } + + private static bool IsSameUtcDate(DateTime? a, DateTime? b) + { + if (!a.HasValue && !b.HasValue) return true; + if (!a.HasValue || !b.HasValue) return false; + return DateTime.SpecifyKind(a.Value.Date, DateTimeKind.Utc) == DateTime.SpecifyKind(b.Value.Date, DateTimeKind.Utc); + } + private static string OnlyDigits(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0ce9824..0c8f76a 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -170,6 +170,7 @@ public class AppDbContext : IdentityDbContext x.Cliente); e.HasIndex(x => x.Linha); e.HasIndex(x => x.DtTerminoFidelizacao); + e.HasIndex(x => x.AutoRenewReferenceEndDate); e.HasIndex(x => x.TenantId); }); diff --git a/Dtos/LinesBatchExcelPreviewDtos.cs b/Dtos/LinesBatchExcelPreviewDtos.cs new file mode 100644 index 0000000..7356198 --- /dev/null +++ b/Dtos/LinesBatchExcelPreviewDtos.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace line_gestao_api.Dtos +{ + public sealed class LinesBatchExcelPreviewResultDto + { + public string? FileName { get; set; } + public string? SheetName { get; set; } + public int NextItemStart { get; set; } + public int TotalRows { get; set; } + public int ValidRows { get; set; } + public int InvalidRows { get; set; } + public int DuplicateRows { get; set; } + public bool CanProceed { get; set; } + public List HeaderErrors { get; set; } = new(); + public List HeaderWarnings { get; set; } = new(); + public List Rows { get; set; } = new(); + } + + public sealed class LinesBatchExcelPreviewRowDto + { + public int SourceRowNumber { get; set; } + public int? SourceItem { get; set; } + public int? GeneratedItemPreview { get; set; } + public bool Valid { get; set; } + public bool DuplicateLinhaInFile { get; set; } + public bool DuplicateChipInFile { get; set; } + public bool DuplicateLinhaInSystem { get; set; } + public bool DuplicateChipInSystem { get; set; } + public CreateMobileLineDto Data { get; set; } = new(); + public List Errors { get; set; } = new(); + public List Warnings { get; set; } = new(); + } + + public sealed class LinesBatchExcelIssueDto + { + public string? Column { get; set; } + public string Message { get; set; } = string.Empty; + } + + public sealed class AssignReservaLinesRequestDto + { + public string? ClienteDestino { get; set; } + public string? UsuarioDestino { get; set; } + public string? SkilDestino { get; set; } + public List LineIds { get; set; } = new(); + } + + public sealed class MoveLinesToReservaRequestDto + { + public List LineIds { get; set; } = new(); + } + + public sealed class AssignReservaLinesResultDto + { + public int Requested { get; set; } + public int Updated { get; set; } + public int Failed { get; set; } + public List Items { get; set; } = new(); + } + + public sealed class AssignReservaLineItemResultDto + { + public Guid Id { get; set; } + public int Item { get; set; } + public string? Linha { get; set; } + public string? Chip { get; set; } + public string? ClienteAnterior { get; set; } + public string? ClienteNovo { get; set; } + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/Dtos/VigenciaDtos.cs b/Dtos/VigenciaDtos.cs index ccadec0..ce64cfa 100644 --- a/Dtos/VigenciaDtos.cs +++ b/Dtos/VigenciaDtos.cs @@ -14,6 +14,10 @@ namespace line_gestao_api.Dtos public string? PlanoContrato { get; set; } public DateTime? DtEfetivacaoServico { get; set; } public DateTime? DtTerminoFidelizacao { get; set; } + public int? AutoRenewYears { get; set; } + public DateTime? AutoRenewReferenceEndDate { get; set; } + public DateTime? AutoRenewConfiguredAt { get; set; } + public DateTime? LastAutoRenewedAt { get; set; } public decimal? Total { get; set; } } @@ -49,6 +53,11 @@ namespace line_gestao_api.Dtos public decimal? Total { get; set; } } + public class ConfigureVigenciaRenewalRequest + { + public int Years { get; set; } + } + public class VigenciaClientGroupDto { public string Cliente { get; set; } = ""; diff --git a/Migrations/20260227120000_AddVigenciaAutoRenewal.cs b/Migrations/20260227120000_AddVigenciaAutoRenewal.cs new file mode 100644 index 0000000..571fbfd --- /dev/null +++ b/Migrations/20260227120000_AddVigenciaAutoRenewal.cs @@ -0,0 +1,68 @@ +using line_gestao_api.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260227120000_AddVigenciaAutoRenewal")] + public partial class AddVigenciaAutoRenewal : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AutoRenewConfiguredAt", + table: "VigenciaLines", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "AutoRenewReferenceEndDate", + table: "VigenciaLines", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "AutoRenewYears", + table: "VigenciaLines", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastAutoRenewedAt", + table: "VigenciaLines", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_VigenciaLines_AutoRenewReferenceEndDate", + table: "VigenciaLines", + column: "AutoRenewReferenceEndDate"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_VigenciaLines_AutoRenewReferenceEndDate", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "AutoRenewConfiguredAt", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "AutoRenewReferenceEndDate", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "AutoRenewYears", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "LastAutoRenewedAt", + table: "VigenciaLines"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 0d13ba2..2b375cf 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -1510,6 +1510,15 @@ namespace line_gestao_api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AutoRenewConfiguredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AutoRenewReferenceEndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AutoRenewYears") + .HasColumnType("integer"); + b.Property("Cliente") .HasColumnType("text"); @@ -1528,6 +1537,9 @@ namespace line_gestao_api.Migrations b.Property("Item") .HasColumnType("integer"); + b.Property("LastAutoRenewedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Linha") .HasColumnType("text"); @@ -1550,6 +1562,8 @@ namespace line_gestao_api.Migrations b.HasIndex("Cliente"); + b.HasIndex("AutoRenewReferenceEndDate"); + b.HasIndex("DtTerminoFidelizacao"); b.HasIndex("Item"); diff --git a/Models/VigenciaLine.cs b/Models/VigenciaLine.cs index f8c37c9..165300c 100644 --- a/Models/VigenciaLine.cs +++ b/Models/VigenciaLine.cs @@ -20,6 +20,10 @@ namespace line_gestao_api.Models public DateTime? DtEfetivacaoServico { get; set; } public DateTime? DtTerminoFidelizacao { get; set; } + public int? AutoRenewYears { get; set; } + public DateTime? AutoRenewReferenceEndDate { get; set; } + public DateTime? AutoRenewConfiguredAt { get; set; } + public DateTime? LastAutoRenewedAt { get; set; } public decimal? Total { get; set; } diff --git a/Program.cs b/Program.cs index 5be119e..5ad43ac 100644 --- a/Program.cs +++ b/Program.cs @@ -94,6 +94,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => diff --git a/Services/GeralSpreadsheetTemplateService.cs b/Services/GeralSpreadsheetTemplateService.cs new file mode 100644 index 0000000..2f4ca25 --- /dev/null +++ b/Services/GeralSpreadsheetTemplateService.cs @@ -0,0 +1,180 @@ +using ClosedXML.Excel; + +namespace line_gestao_api.Services +{ + public class GeralSpreadsheetTemplateService + { + private const string WorksheetName = "GERAL"; + private const int HeaderRow = 1; + private const int FirstDataRow = 2; + private const int LastColumn = 31; // A..AE + + private static readonly string[] Headers = + { + "CONTA", + "LINHA", + "CHIP", + "CLIENTE", + "USUÁRIO", + "PLANO CONTRATO", + "FRAQUIA", + "VALOR DO PLANO R$", + "GESTÃO VOZ E DADOS R$", + "SKEELO", + "VIVO NEWS PLUS", + "VIVO TRAVEL MUNDO", + "VIVO SYNC", + "VIVO GESTÃO DISPOSITIVO", + "VALOR CONTRATO VIVO", + "FRANQUIA LINE", + "FRANQUIA GESTÃO", + "LOCAÇÃO AP.", + "VALOR CONTRATO LINE", + "DESCONTO", + "LUCRO", + "STATUS", + "DATA DO BLOQUEIO", + "SKIL", + "MODALIDADE", + "CEDENTE", + "SOLICITANTE", + "DATA DA ENTREGA OPERA.", + "DATA DA ENTREGA CLIENTE", + "VENC. DA CONTA", + "TIPO DE CHIP" + }; + + private static readonly double[] ColumnWidths = + { + 11.00, 12.00, 21.43, 70.14, 58.29, 27.71, 11.00, 18.14, + 20.71, 11.00, 14.57, 18.14, 11.00, 19.57, 21.43, 14.57, + 16.29, 13.00, 20.71, 13.00, 13.00, 14.57, 16.29, 13.00, + 16.29, 39.43, 27.86, 25.00, 27.71, 16.29, 13.00 + }; + + private static readonly int[] TextColumns = + { + 1, 2, 3, 4, 5, 6, 7, 16, 17, 22, 24, 25, 26, 27, 31 + }; + + private static readonly int[] CurrencyColumns = + { + 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21 + }; + + private static readonly int[] DateColumns = + { + 23, 28, 29, 30 + }; + + public byte[] BuildPlanilhaGeralTemplate() + { + using var workbook = new XLWorkbook(); + var ws = workbook.Worksheets.Add(WorksheetName); + + BuildHeader(ws); + ConfigureColumns(ws); + ConfigureDataFormatting(ws); + ConfigureSheetView(ws); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + private static void BuildHeader(IXLWorksheet ws) + { + for (var i = 0; i < Headers.Length; i++) + { + ws.Cell(HeaderRow, i + 1).Value = Headers[i]; + } + + var headerRange = ws.Range(HeaderRow, 1, HeaderRow, LastColumn); + headerRange.Style.Font.FontName = "Calibri"; + headerRange.Style.Font.FontSize = 11; + headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + headerRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + headerRange.Style.Border.TopBorder = XLBorderStyleValues.Thin; + headerRange.Style.Border.BottomBorder = XLBorderStyleValues.Thin; + headerRange.Style.Border.LeftBorder = XLBorderStyleValues.Thin; + headerRange.Style.Border.RightBorder = XLBorderStyleValues.Thin; + + ws.Row(HeaderRow).Height = 14.25; + + var navy = XLColor.FromHtml("#002060"); + var purple = XLColor.FromHtml("#7030A0"); + var orange = XLColor.FromHtml("#D9A87E"); + var red = XLColor.FromHtml("#FF0000"); + var yellow = XLColor.FromHtml("#FFFF00"); + var white = XLColor.White; + var black = XLColor.Black; + + ApplyHeaderBlock(ws, 1, 6, navy, white, bold: true); // A-F + ApplyHeaderBlock(ws, 22, 31, navy, white, bold: true); // V-AE + ApplyHeaderBlock(ws, 7, 15, purple, white, bold: true); // G-O + ApplyHeaderBlock(ws, 16, 19, orange, white, bold: true);// P-S + ApplyHeaderBlock(ws, 20, 20, red, white, bold: true); // T + ApplyHeaderBlock(ws, 21, 21, yellow, black, bold: true);// U + + // Exceções no bloco azul (sem negrito): CHIP, CLIENTE, USUÁRIO => C, D, E + ws.Cell(1, 3).Style.Font.Bold = false; + ws.Cell(1, 4).Style.Font.Bold = false; + ws.Cell(1, 5).Style.Font.Bold = false; + } + + private static void ApplyHeaderBlock( + IXLWorksheet ws, + int startCol, + int endCol, + XLColor bgColor, + XLColor fontColor, + bool bold) + { + var range = ws.Range(HeaderRow, startCol, HeaderRow, endCol); + range.Style.Fill.BackgroundColor = bgColor; + range.Style.Font.FontColor = fontColor; + range.Style.Font.Bold = bold; + } + + private static void ConfigureColumns(IXLWorksheet ws) + { + for (var i = 0; i < ColumnWidths.Length; i++) + { + ws.Column(i + 1).Width = ColumnWidths[i]; + } + } + + private static void ConfigureDataFormatting(IXLWorksheet ws) + { + // Prepara um range vazio com estilo base para facilitar preenchimento manual + var dataPreviewRange = ws.Range(FirstDataRow, 1, 1000, LastColumn); + dataPreviewRange.Style.Font.FontName = "Calibri"; + dataPreviewRange.Style.Font.FontSize = 11; + dataPreviewRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + foreach (var col in TextColumns) + { + ws.Column(col).Style.NumberFormat.Format = "@"; + } + + foreach (var col in CurrencyColumns) + { + ws.Column(col).Style.NumberFormat.Format = "\"R$\" #,##0.00"; + } + + foreach (var col in DateColumns) + { + ws.Column(col).Style.DateFormat.Format = "dd/MM/yyyy"; + } + + // O campo ITÉM é gerado internamente pelo sistema e não faz parte do template. + } + + private static void ConfigureSheetView(IXLWorksheet ws) + { + ws.ShowGridLines = false; + ws.SheetView.FreezeRows(1); // Freeze em A2 (mantém linha 1 fixa) + ws.Range(1, 1, 1, LastColumn).SetAutoFilter(); + } + } +} diff --git a/Services/VigenciaNotificationSyncService.cs b/Services/VigenciaNotificationSyncService.cs index 45b4f44..63ab080 100644 --- a/Services/VigenciaNotificationSyncService.cs +++ b/Services/VigenciaNotificationSyncService.cs @@ -83,6 +83,8 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService .Where(u => !string.IsNullOrWhiteSpace(u.Email)) .ToDictionary(u => u.Email!.Trim().ToLowerInvariant(), u => u.Id); + await ApplyAutoRenewalsAsync(tenantId, today, userByName, userByEmail, cancellationToken); + var vigencias = await _db.VigenciaLines.AsNoTracking() .Where(v => v.DtTerminoFidelizacao != null) .ToListAsync(cancellationToken); @@ -213,6 +215,112 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService await _db.SaveChangesAsync(cancellationToken); } + private async Task ApplyAutoRenewalsAsync( + Guid tenantId, + DateTime todayUtc, + IReadOnlyDictionary userByName, + IReadOnlyDictionary userByEmail, + CancellationToken cancellationToken) + { + var scheduledLines = await _db.VigenciaLines + .Where(v => + v.AutoRenewYears != null && + v.AutoRenewReferenceEndDate != null && + v.DtTerminoFidelizacao != null) + .ToListAsync(cancellationToken); + + if (scheduledLines.Count == 0) + { + return; + } + + var changed = false; + var autoRenewNotifications = new List(); + var nowUtc = DateTime.UtcNow; + + foreach (var vigencia in scheduledLines) + { + var years = NormalizeAutoRenewYears(vigencia.AutoRenewYears); + if (!years.HasValue || !vigencia.DtTerminoFidelizacao.HasValue || !vigencia.AutoRenewReferenceEndDate.HasValue) + { + if (vigencia.AutoRenewYears.HasValue || vigencia.AutoRenewReferenceEndDate.HasValue || vigencia.AutoRenewConfiguredAt.HasValue) + { + ClearAutoRenewSchedule(vigencia); + vigencia.UpdatedAt = nowUtc; + changed = true; + } + continue; + } + + var currentEndUtc = ToUtcDate(vigencia.DtTerminoFidelizacao.Value); + var referenceEndUtc = ToUtcDate(vigencia.AutoRenewReferenceEndDate.Value); + + // As datas de vigência foram alteradas manualmente após o agendamento: + // não renova automaticamente e limpa o agendamento. + if (currentEndUtc != referenceEndUtc) + { + ClearAutoRenewSchedule(vigencia); + vigencia.UpdatedAt = nowUtc; + changed = true; + continue; + } + + // Só executa a renovação no vencimento (ou se já passou e segue sem alteração manual). + if (currentEndUtc > todayUtc) + { + continue; + } + + var newStartUtc = currentEndUtc.AddDays(1); + var newEndUtc = currentEndUtc.AddYears(years.Value); + + vigencia.DtEfetivacaoServico = newStartUtc; + vigencia.DtTerminoFidelizacao = newEndUtc; + vigencia.LastAutoRenewedAt = nowUtc; + ClearAutoRenewSchedule(vigencia); + vigencia.UpdatedAt = nowUtc; + changed = true; + + autoRenewNotifications.Add(BuildAutoRenewNotification( + vigencia, + years.Value, + currentEndUtc, + newEndUtc, + ResolveUserId(vigencia.Usuario, userByName, userByEmail), + tenantId)); + } + + if (!changed && autoRenewNotifications.Count == 0) + { + return; + } + + if (autoRenewNotifications.Count > 0) + { + var dedupKeys = autoRenewNotifications + .Select(n => n.DedupKey) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var existingDedupKeys = await _db.Notifications.AsNoTracking() + .Where(n => dedupKeys.Contains(n.DedupKey)) + .Select(n => n.DedupKey) + .ToListAsync(cancellationToken); + + var existingSet = existingDedupKeys.ToHashSet(StringComparer.Ordinal); + autoRenewNotifications = autoRenewNotifications + .Where(n => !existingSet.Contains(n.DedupKey)) + .ToList(); + + if (autoRenewNotifications.Count > 0) + { + await _db.Notifications.AddRangeAsync(autoRenewNotifications, cancellationToken); + } + } + + await _db.SaveChangesAsync(cancellationToken); + } + private async Task CleanupOutdatedNotificationsAsync( IReadOnlyCollection vigencias, bool notifyAllFutureDates, @@ -349,6 +457,38 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService }; } + private static Notification BuildAutoRenewNotification( + VigenciaLine vigencia, + int years, + DateTime previousEndUtc, + DateTime newEndUtc, + Guid? userId, + Guid tenantId) + { + var linha = vigencia.Linha?.Trim(); + var cliente = vigencia.Cliente?.Trim(); + var usuario = vigencia.Usuario?.Trim(); + var dedupKey = BuildAutoRenewDedupKey(tenantId, vigencia.Id, previousEndUtc, years); + + return new Notification + { + Tipo = "RenovacaoAutomatica", + Titulo = $"Renovação automática concluída{FormatLinha(linha)}", + Mensagem = $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} foi renovada automaticamente por {years} ano(s): {previousEndUtc:dd/MM/yyyy} → {newEndUtc:dd/MM/yyyy}.", + Data = DateTime.UtcNow, + ReferenciaData = newEndUtc, + DiasParaVencer = null, + Lida = false, + DedupKey = dedupKey, + UserId = userId, + Usuario = usuario, + Cliente = cliente, + Linha = linha, + VigenciaLineId = vigencia.Id, + TenantId = tenantId + }; + } + private static string BuildDedupKey( string tipo, DateTime referenciaData, @@ -370,6 +510,59 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService return string.Join('|', parts); } + private static string BuildAutoRenewDedupKey(Guid tenantId, Guid vigenciaLineId, DateTime referenceEndDateUtc, int years) + { + return string.Join('|', new[] + { + "renovacaoautomatica", + tenantId.ToString("N"), + vigenciaLineId.ToString("N"), + referenceEndDateUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + years.ToString(CultureInfo.InvariantCulture) + }); + } + + private static Guid? ResolveUserId( + string? usuario, + IReadOnlyDictionary userByName, + IReadOnlyDictionary userByEmail) + { + var key = usuario?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + + if (userByEmail.TryGetValue(key, out var byEmail)) + { + return byEmail; + } + + if (userByName.TryGetValue(key, out var byName)) + { + return byName; + } + + return null; + } + + private static int? NormalizeAutoRenewYears(int? years) + { + return years == 2 ? years : null; + } + + private static DateTime ToUtcDate(DateTime value) + { + return DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); + } + + private static void ClearAutoRenewSchedule(VigenciaLine vigencia) + { + vigencia.AutoRenewYears = null; + vigencia.AutoRenewReferenceEndDate = null; + vigencia.AutoRenewConfiguredAt = null; + } + private static string FormatLinha(string? linha) { return string.IsNullOrWhiteSpace(linha) ? string.Empty : $" {linha}"; From 242f8bc7077abe4019c9a879a6ace43b655171af Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 27 Feb 2026 16:54:20 -0300 Subject: [PATCH 2/2] Feat: Corrigindo merge --- .gitignore | 3 + Controllers/AuthController.cs | 25 +- Controllers/BillingController.cs | 6 +- Controllers/ChipsVirgensController.cs | 8 +- Controllers/ControleRecebidosController.cs | 8 +- Controllers/HistoricoController.cs | 2 +- Controllers/LinesController.cs | 33 +- Controllers/MuregController.cs | 8 +- Controllers/ParcelamentosController.cs | 8 +- Controllers/SystemTenantUsersController.cs | 226 ++++++++++ Controllers/SystemTenantsController.cs | 56 +++ Controllers/TemplatesController.cs | 2 +- Controllers/TrocaNumeroController.cs | 6 +- Controllers/UserDataController.cs | 6 +- Controllers/UsersController.cs | 21 +- Controllers/VigenciaController.cs | 6 +- Data/AppDbContext.cs | 73 ++-- Data/SeedData.cs | 385 ++++++++--------- Dtos/SystemTenantDtos.cs | 23 + ...reateTenantsAndAuditLogsSystemContracts.cs | 110 +++++ ...130100_AddTenantIdToMobileLinesIfNeeded.cs | 36 ++ ...llTenantsFromDistinctMobileLinesCliente.cs | 74 ++++ ...lMobileLinesTenantIdFromTenantSourceKey.cs | 47 +++ ...akeMobileLinesTenantIdNotNullAndIndexes.cs | 27 ++ Migrations/AppDbContextModelSnapshot.cs | 43 +- Models/AuditLog.cs | 7 + Models/Tenant.cs | 6 +- Program.cs | 14 +- Services/AppRoles.cs | 10 + Services/AuditLogBuilder.cs | 22 +- Services/DeterministicGuid.cs | 20 + Services/ISystemAuditService.cs | 6 + Services/ITenantProvider.cs | 2 + Services/SystemAuditActions.cs | 8 + Services/SystemAuditService.cs | 115 +++++ Services/SystemTenantConstants.cs | 11 + Services/TenantProvider.cs | 23 + .../VigenciaNotificationBackgroundService.cs | 5 +- appsettings.Development.json | 7 +- appsettings.Local.example.json | 7 +- appsettings.json | 9 +- .../GeralDashboardInsightsServiceTests.cs | 4 + .../SystemTenantIntegrationTests.cs | 335 +++++++++++++++ .../line-gestao-api.Tests.csproj | 1 + ...-MultiTenant-Tests.postman_collection.json | 395 ++++++++++++++++++ ...MultiTenant-Tests.postman_environment.json | 64 +++ 46 files changed, 2015 insertions(+), 298 deletions(-) create mode 100644 Controllers/SystemTenantUsersController.cs create mode 100644 Controllers/SystemTenantsController.cs create mode 100644 Dtos/SystemTenantDtos.cs create mode 100644 Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs create mode 100644 Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs create mode 100644 Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs create mode 100644 Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs create mode 100644 Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs create mode 100644 Services/AppRoles.cs create mode 100644 Services/DeterministicGuid.cs create mode 100644 Services/ISystemAuditService.cs create mode 100644 Services/SystemAuditActions.cs create mode 100644 Services/SystemAuditService.cs create mode 100644 Services/SystemTenantConstants.cs create mode 100644 line-gestao-api.Tests/SystemTenantIntegrationTests.cs create mode 100644 postman/SystemTenant-MultiTenant-Tests.postman_collection.json create mode 100644 postman/SystemTenant-MultiTenant-Tests.postman_environment.json diff --git a/.gitignore b/.gitignore index ce2b5ee..05cd65c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # dotenv files .env appsettings.Local.json +appsettings*.json +line-gestao-api.csproj +line-gestao-api.http # User-specific files *.rsuser diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index fe2251d..8d7ac24 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -36,7 +36,7 @@ public class AuthController : ControllerBase } [HttpPost("register")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Register(RegisterRequest req) { if (req.Password != req.ConfirmPassword) @@ -69,7 +69,7 @@ public class AuthController : ControllerBase if (!createResult.Succeeded) return BadRequest(createResult.Errors.Select(e => e.Description).ToList()); - await _userManager.AddToRoleAsync(user, "leitura"); + await _userManager.AddToRoleAsync(user, AppRoles.Cliente); var effectiveTenantId = await EnsureValidTenantIdAsync(user); if (!effectiveTenantId.HasValue) @@ -192,22 +192,19 @@ public class AuthController : ControllerBase private async Task EnsureValidTenantIdAsync(ApplicationUser user) { - if (user.TenantId != Guid.Empty) - return user.TenantId; + if (user.TenantId == Guid.Empty) + { + return null; + } - var fallbackTenantId = await _db.Tenants + var existsAndActive = await _db.Tenants .AsNoTracking() - .OrderBy(t => t.CreatedAt) - .Select(t => (Guid?)t.Id) - .FirstOrDefaultAsync(); + .AnyAsync(t => t.Id == user.TenantId && t.Ativo); - if (!fallbackTenantId.HasValue || fallbackTenantId.Value == Guid.Empty) - return null; - - user.TenantId = fallbackTenantId.Value; - var updateResult = await _userManager.UpdateAsync(user); - if (!updateResult.Succeeded) + if (!existsAndActive) + { return null; + } return user.TenantId; } diff --git a/Controllers/BillingController.cs b/Controllers/BillingController.cs index 55d8aac..82e3077 100644 --- a/Controllers/BillingController.cs +++ b/Controllers/BillingController.cs @@ -10,7 +10,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public class BillingController : ControllerBase { private readonly AppDbContext _db; @@ -197,7 +197,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateBillingClientRequest req) { var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); @@ -230,7 +230,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/ChipsVirgensController.cs b/Controllers/ChipsVirgensController.cs index 62b88ac..4c1a3f1 100644 --- a/Controllers/ChipsVirgensController.cs +++ b/Controllers/ChipsVirgensController.cs @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/chips-virgens")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public class ChipsVirgensController : ControllerBase { private readonly AppDbContext _db; @@ -93,7 +93,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateChipVirgemDto req) { var now = DateTime.UtcNow; @@ -122,7 +122,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateChipVirgemRequest req) { var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id); @@ -139,7 +139,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/ControleRecebidosController.cs b/Controllers/ControleRecebidosController.cs index 918a73b..1457b72 100644 --- a/Controllers/ControleRecebidosController.cs +++ b/Controllers/ControleRecebidosController.cs @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/controle-recebidos")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public class ControleRecebidosController : ControllerBase { private readonly AppDbContext _db; @@ -138,7 +138,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateControleRecebidoDto req) { var now = DateTime.UtcNow; @@ -195,7 +195,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateControleRecebidoRequest req) { var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id); @@ -223,7 +223,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index 4e37612..8ec234b 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers; [ApiController] [Route("api/historico")] -[Authorize(Roles = "admin")] +[Authorize(Roles = "sysadmin,gestor")] public class HistoricoController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index fc3c89a..7418142 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -105,6 +105,9 @@ namespace line_gestao_api.Controllers query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + if (!reservaFilter) + query = ExcludeReservaContext(query); + query = ApplyAdditionalFilters(query, additionalMode, additionalServices); IQueryable groupedQuery; @@ -232,6 +235,9 @@ namespace line_gestao_api.Controllers query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + if (!reservaFilter) + query = ExcludeReservaContext(query); + query = ApplyAdditionalFilters(query, additionalMode, additionalServices); List clients; @@ -468,6 +474,9 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + if (!reservaFilter) + q = ExcludeReservaContext(q); + q = ApplyAdditionalFilters(q, additionalMode, additionalServices); var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); @@ -686,7 +695,7 @@ namespace line_gestao_api.Controllers // ✅ 5. CREATE // ========================================================== [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateMobileLineDto req) { if (string.IsNullOrWhiteSpace(req.Cliente)) @@ -796,7 +805,7 @@ namespace line_gestao_api.Controllers // ✅ 5.1. CREATE BATCH // ========================================================== [HttpPost("batch")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> CreateBatch([FromBody] CreateMobileLinesBatchRequestDto req) { var requests = req?.Lines ?? new List(); @@ -976,7 +985,7 @@ namespace line_gestao_api.Controllers // ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL) // ========================================================== [HttpPost("batch/import-preview")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] [Consumes("multipart/form-data")] [RequestSizeLimit(20_000_000)] public async Task> PreviewBatchImportExcel([FromForm] ImportExcelForm form) @@ -1006,7 +1015,7 @@ namespace line_gestao_api.Controllers // ✅ 5.3. ATRIBUIR LINHAS DA RESERVA PARA CLIENTE // ========================================================== [HttpPost("reserva/assign-client")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req) { var ids = (req?.LineIds ?? new List()) @@ -1146,7 +1155,7 @@ namespace line_gestao_api.Controllers // ✅ 5.4. MOVER LINHAS DE CLIENTE PARA RESERVA // ========================================================== [HttpPost("move-to-reserva")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req) { var ids = (req?.LineIds ?? new List()) @@ -1251,7 +1260,7 @@ namespace line_gestao_api.Controllers // ✅ 6. UPDATE // ========================================================== [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateMobileLineRequest req) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); @@ -1345,7 +1354,7 @@ namespace line_gestao_api.Controllers // ✅ 7. DELETE // ========================================================== [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); @@ -1360,7 +1369,7 @@ namespace line_gestao_api.Controllers // ✅ 8. IMPORT EXCEL // ========================================================== [HttpPost("import-excel")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] public async Task> ImportExcel([FromForm] ImportExcelForm form) @@ -4336,6 +4345,14 @@ namespace line_gestao_api.Controllers }); } + private static IQueryable ExcludeReservaContext(IQueryable query) + { + return query.Where(x => + !EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") && + !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") && + !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + } + private static IQueryable ApplyAdditionalFilters( IQueryable query, string? additionalMode, diff --git a/Controllers/MuregController.cs b/Controllers/MuregController.cs index 1ebd16b..4ec5909 100644 --- a/Controllers/MuregController.cs +++ b/Controllers/MuregController.cs @@ -187,7 +187,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateMuregDto req) { if (req.MobileLineId == Guid.Empty) @@ -289,7 +289,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateMuregDto req) { var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); @@ -361,7 +361,7 @@ namespace line_gestao_api.Controllers // Exclui registro MUREG // ========================================================== [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); @@ -377,7 +377,7 @@ namespace line_gestao_api.Controllers // ✅ POST: /api/mureg/import-excel (mantido) // ========================================================== [HttpPost("import-excel")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] public async Task ImportExcel([FromForm] ImportExcelForm form) diff --git a/Controllers/ParcelamentosController.cs b/Controllers/ParcelamentosController.cs index 6d749f3..71b9f75 100644 --- a/Controllers/ParcelamentosController.cs +++ b/Controllers/ParcelamentosController.cs @@ -9,7 +9,7 @@ namespace line_gestao_api.Controllers; [ApiController] [Route("api/parcelamentos")] -[Authorize(Roles = "admin")] +[Authorize(Roles = "sysadmin,gestor")] public class ParcelamentosController : ControllerBase { private readonly AppDbContext _db; @@ -165,7 +165,7 @@ public class ParcelamentosController : ControllerBase } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] ParcelamentoUpsertDto req) { var now = DateTime.UtcNow; @@ -202,7 +202,7 @@ public class ParcelamentosController : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] ParcelamentoUpsertDto req) { var entity = await _db.ParcelamentoLines @@ -239,7 +239,7 @@ public class ParcelamentosController : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id); diff --git a/Controllers/SystemTenantUsersController.cs b/Controllers/SystemTenantUsersController.cs new file mode 100644 index 0000000..6e176a5 --- /dev/null +++ b/Controllers/SystemTenantUsersController.cs @@ -0,0 +1,226 @@ +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Models; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/system/tenants/{tenantId:guid}/users")] +[Authorize(Policy = "SystemAdmin")] +public class SystemTenantUsersController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly UserManager _userManager; + private readonly RoleManager> _roleManager; + private readonly ISystemAuditService _systemAuditService; + + public SystemTenantUsersController( + AppDbContext db, + UserManager userManager, + RoleManager> roleManager, + ISystemAuditService systemAuditService) + { + _db = db; + _userManager = userManager; + _roleManager = roleManager; + _systemAuditService = systemAuditService; + } + + [HttpPost] + public async Task> CreateUserForTenant( + [FromRoute] Guid tenantId, + [FromBody] CreateSystemTenantUserRequest request) + { + if (tenantId == Guid.Empty) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "TenantId inválido.", "invalid_tenant_id"); + } + + var tenant = await _db.Tenants.AsNoTracking().FirstOrDefaultAsync(t => t.Id == tenantId); + if (tenant == null) + { + return await RejectAsync(tenantId, StatusCodes.Status404NotFound, "Tenant não encontrado.", "tenant_not_found"); + } + + if (string.IsNullOrWhiteSpace(request.Email)) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Email é obrigatório.", "missing_email"); + } + + if (string.IsNullOrWhiteSpace(request.Password)) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Password é obrigatória.", "missing_password"); + } + + if (request.Roles == null || request.Roles.Count == 0) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Informe ao menos uma role.", "missing_roles"); + } + + var normalizedRoles = request.Roles + .Where(r => !string.IsNullOrWhiteSpace(r)) + .Select(r => r.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (normalizedRoles.Count == 0) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Roles inválidas.", "invalid_roles"); + } + + var unsupportedRoles = normalizedRoles + .Where(role => !AppRoles.All.Contains(role, StringComparer.OrdinalIgnoreCase)) + .ToList(); + if (unsupportedRoles.Count > 0) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + $"Roles não suportadas: {string.Join(", ", unsupportedRoles)}. Use apenas: {string.Join(", ", AppRoles.All)}.", + "unsupported_roles"); + } + + if (!tenant.IsSystem && normalizedRoles.Contains(SystemTenantConstants.SystemRole)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + "A role sysadmin só pode ser usada no SystemTenant.", + "invalid_sysadmin_outside_system_tenant"); + } + + if (tenant.IsSystem && normalizedRoles.Any(r => r != SystemTenantConstants.SystemRole)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + "No SystemTenant é permitido apenas a role sysadmin.", + "invalid_non_system_role_for_system_tenant"); + } + + foreach (var role in normalizedRoles) + { + if (!await _roleManager.RoleExistsAsync(role)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + $"Role inexistente: {role}", + "role_not_found"); + } + } + + var email = request.Email.Trim().ToLowerInvariant(); + var normalizedEmail = _userManager.NormalizeEmail(email); + + var alreadyExists = await _userManager.Users + .IgnoreQueryFilters() + .AnyAsync(u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail); + + if (alreadyExists) + { + return await RejectAsync(tenantId, StatusCodes.Status409Conflict, "Já existe usuário com este email neste tenant.", "email_exists"); + } + + var name = string.IsNullOrWhiteSpace(request.Name) + ? email + : request.Name.Trim(); + + var user = new ApplicationUser + { + Name = name, + Email = email, + UserName = email, + TenantId = tenantId, + EmailConfirmed = true, + IsActive = true, + LockoutEnabled = true + }; + + IdentityResult createResult; + try + { + createResult = await _userManager.CreateAsync(user, request.Password); + } + catch (DbUpdateException) + { + return await RejectAsync( + tenantId, + StatusCodes.Status409Conflict, + "Não foi possível criar usuário. Email/username já em uso.", + "db_conflict"); + } + + if (!createResult.Succeeded) + { + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUserRejected, + targetTenantId: tenantId, + metadata: new + { + reason = "identity_create_failed", + email, + errors = createResult.Errors.Select(e => e.Description).ToList() + }); + + return BadRequest(createResult.Errors.Select(e => e.Description).ToList()); + } + + var addRolesResult = await _userManager.AddToRolesAsync(user, normalizedRoles); + if (!addRolesResult.Succeeded) + { + await _userManager.DeleteAsync(user); + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUserRejected, + targetTenantId: tenantId, + metadata: new + { + reason = "identity_add_roles_failed", + email, + roles = normalizedRoles, + errors = addRolesResult.Errors.Select(e => e.Description).ToList() + }); + + return BadRequest(addRolesResult.Errors.Select(e => e.Description).ToList()); + } + + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUser, + targetTenantId: tenantId, + metadata: new + { + createdUserId = user.Id, + email, + roles = normalizedRoles + }); + + var response = new SystemTenantUserCreatedDto + { + UserId = user.Id, + TenantId = tenantId, + Email = email, + Roles = normalizedRoles + }; + + return StatusCode(StatusCodes.Status201Created, response); + } + + private async Task> RejectAsync( + Guid targetTenantId, + int statusCode, + string message, + string reason) + { + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUserRejected, + targetTenantId: targetTenantId, + metadata: new { reason, message }); + + return StatusCode(statusCode, message); + } +} diff --git a/Controllers/SystemTenantsController.cs b/Controllers/SystemTenantsController.cs new file mode 100644 index 0000000..5d38b61 --- /dev/null +++ b/Controllers/SystemTenantsController.cs @@ -0,0 +1,56 @@ +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/system/tenants")] +[Authorize(Policy = "SystemAdmin")] +public class SystemTenantsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ISystemAuditService _systemAuditService; + + public SystemTenantsController(AppDbContext db, ISystemAuditService systemAuditService) + { + _db = db; + _systemAuditService = systemAuditService; + } + + [HttpGet] + public async Task>> GetTenants( + [FromQuery] string source = SystemTenantConstants.MobileLinesClienteSourceType, + [FromQuery] bool active = true) + { + var query = _db.Tenants + .AsNoTracking() + .Where(t => !t.IsSystem); + + if (!string.IsNullOrWhiteSpace(source)) + { + query = query.Where(t => t.SourceType == source); + } + + query = query.Where(t => t.Ativo == active); + + var tenants = await query + .OrderBy(t => t.NomeOficial) + .Select(t => new SystemTenantListItemDto + { + TenantId = t.Id, + NomeOficial = t.NomeOficial + }) + .ToListAsync(); + + await _systemAuditService.LogAsync( + action: SystemAuditActions.ListTenants, + targetTenantId: SystemTenantConstants.SystemTenantId, + metadata: new { source, active, returnedCount = tenants.Count }); + + return Ok(tenants); + } +} diff --git a/Controllers/TemplatesController.cs b/Controllers/TemplatesController.cs index 98a7e6a..adb254a 100644 --- a/Controllers/TemplatesController.cs +++ b/Controllers/TemplatesController.cs @@ -6,7 +6,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/templates")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public class TemplatesController : ControllerBase { private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService; diff --git a/Controllers/TrocaNumeroController.cs b/Controllers/TrocaNumeroController.cs index 759764a..50111f0 100644 --- a/Controllers/TrocaNumeroController.cs +++ b/Controllers/TrocaNumeroController.cs @@ -112,7 +112,7 @@ namespace line_gestao_api.Controllers // ✅ CREATE // ========================================================== [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateTrocaNumeroDto req) { var now = DateTime.UtcNow; @@ -141,7 +141,7 @@ namespace line_gestao_api.Controllers // ✅ UPDATE // ========================================================== [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateTrocaNumeroRequest req) { var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); @@ -167,7 +167,7 @@ namespace line_gestao_api.Controllers // ✅ DELETE // ========================================================== [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/UserDataController.cs b/Controllers/UserDataController.cs index 58dbfc8..4fbaf19 100644 --- a/Controllers/UserDataController.cs +++ b/Controllers/UserDataController.cs @@ -263,7 +263,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> Create([FromBody] CreateUserDataRequest req) { var now = DateTime.UtcNow; @@ -365,7 +365,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Update(Guid id, [FromBody] UpdateUserDataRequest req) { var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id); @@ -397,7 +397,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs index 17f4ef7..e37e6b1 100644 --- a/Controllers/UsersController.cs +++ b/Controllers/UsersController.cs @@ -17,8 +17,9 @@ public class UsersController : ControllerBase { private static readonly HashSet AllowedRoles = new(StringComparer.OrdinalIgnoreCase) { - "admin", - "gestor" + AppRoles.SysAdmin, + AppRoles.Gestor, + AppRoles.Cliente }; private readonly AppDbContext _db; @@ -39,7 +40,7 @@ public class UsersController : ControllerBase } [HttpPost] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> Create([FromBody] UserCreateRequest req) { var errors = ValidateCreate(req); @@ -122,7 +123,7 @@ public class UsersController : ControllerBase } [HttpGet] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task>> GetAll( [FromQuery] string? search, [FromQuery] string? permissao, @@ -191,7 +192,7 @@ public class UsersController : ControllerBase } [HttpGet("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> GetById(Guid id) { var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id); @@ -215,7 +216,7 @@ public class UsersController : ControllerBase } [HttpPatch("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Update(Guid id, [FromBody] UserUpdateRequest req) { var errors = await ValidateUpdateAsync(id, req); @@ -295,7 +296,7 @@ public class UsersController : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { if (_tenantProvider.TenantId == null) @@ -334,12 +335,12 @@ public class UsersController : ControllerBase } var targetRoles = await _userManager.GetRolesAsync(user); - var isAdmin = targetRoles.Any(r => string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase)); + var isAdmin = targetRoles.Any(r => string.Equals(r, AppRoles.SysAdmin, StringComparison.OrdinalIgnoreCase)); if (isAdmin) { var adminRoleId = await _roleManager.Roles - .Where(r => r.Name == "admin") + .Where(r => r.Name == AppRoles.SysAdmin) .Select(r => (Guid?)r.Id) .FirstOrDefaultAsync(); @@ -360,7 +361,7 @@ public class UsersController : ControllerBase { Errors = new List { - new() { Field = "usuario", Message = "Não é permitido excluir o último administrador." } + new() { Field = "usuario", Message = "Não é permitido excluir o último sysadmin." } } }); } diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index 4b700e8..5dcf027 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -256,7 +256,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> Create([FromBody] CreateVigenciaRequest req) { var now = DateTime.UtcNow; @@ -354,7 +354,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Update(Guid id, [FromBody] UpdateVigenciaRequest req) { var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); @@ -423,7 +423,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0c8f76a..c086ae5 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -78,6 +78,15 @@ public class AppDbContext : IdentityDbContext(e => + { + e.Property(x => x.NomeOficial).HasMaxLength(200); + e.Property(x => x.SourceType).HasMaxLength(80); + e.Property(x => x.SourceKey).HasMaxLength(300); + e.HasIndex(x => new { x.SourceType, x.SourceKey }).IsUnique(); + e.HasIndex(x => new { x.IsSystem, x.Ativo }); + }); + // ========================= // ✅ USER (Identity) // ========================= @@ -271,6 +280,7 @@ public class AppDbContext : IdentityDbContext(e => { + e.Property(x => x.MetadataJson).HasColumnType("jsonb"); e.Property(x => x.Action).HasMaxLength(20); e.Property(x => x.Page).HasMaxLength(80); e.Property(x => x.EntityName).HasMaxLength(120); @@ -282,8 +292,13 @@ public class AppDbContext : IdentityDbContext x.RequestMethod).HasMaxLength(10); e.Property(x => x.IpAddress).HasMaxLength(80); e.Property(x => x.ChangesJson).HasColumnType("jsonb"); + e.Property(x => x.ActorTenantId); + e.Property(x => x.TargetTenantId); e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.ActorTenantId); + e.HasIndex(x => x.TargetTenantId); + e.HasIndex(x => x.ActorUserId); e.HasIndex(x => x.OccurredAtUtc); e.HasIndex(x => x.Page); e.HasIndex(x => x.UserId); @@ -319,33 +334,33 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); } public override int SaveChanges() @@ -364,12 +379,12 @@ public class AppDbContext : IdentityDbContext().Where(e => e.State == EntityState.Added)) { if (entry.Entity.TenantId == Guid.Empty) diff --git a/Data/SeedData.cs b/Data/SeedData.cs index 52a9132..12b5858 100644 --- a/Data/SeedData.cs +++ b/Data/SeedData.cs @@ -9,17 +9,14 @@ namespace line_gestao_api.Data; public class SeedOptions { public bool Enabled { get; set; } = true; - public string DefaultTenantName { get; set; } = "Default"; - public string AdminName { get; set; } = "Administrador"; - public string AdminEmail { get; set; } = "admin@linegestao.local"; - public string AdminPassword { get; set; } = "DevAdmin123!"; + public string AdminMasterName { get; set; } = "System Admin"; + public string? AdminMasterEmail { get; set; } = "sysadmin@linegestao.local"; + public string? AdminMasterPassword { get; set; } = "DevSysAdmin123!"; public bool ReapplyAdminCredentialsOnStartup { get; set; } = false; } public static class SeedData { - public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - public static async Task EnsureSeedDataAsync(IServiceProvider services) { using var scope = services.CreateScope(); @@ -29,224 +26,236 @@ public static class SeedData var tenantProvider = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService>().Value; - await db.Database.MigrateAsync(); + if (db.Database.IsRelational()) + { + await db.Database.MigrateAsync(); + } + else + { + await db.Database.EnsureCreatedAsync(); + } if (!options.Enabled) { return; } - var roles = new[] { "admin", "gestor", "operador", "leitura" }; + var systemTenantId = SystemTenantConstants.SystemTenantId; + var roles = AppRoles.All; foreach (var role in roles) { if (!await roleManager.RoleExistsAsync(role)) { - await roleManager.CreateAsync(new IdentityRole(role)); + var roleResult = await roleManager.CreateAsync(new IdentityRole(role)); + EnsureIdentitySucceeded(roleResult, $"Falha ao criar role '{role}'."); } } - var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == DefaultTenantId); - if (tenant == null) + await MigrateLegacyRolesAsync(db, roleManager); + + var systemTenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == systemTenantId); + if (systemTenant == null) { - tenant = new Tenant + systemTenant = new Tenant { - Id = DefaultTenantId, - Name = options.DefaultTenantName, + Id = systemTenantId, + NomeOficial = SystemTenantConstants.SystemTenantNomeOficial, + IsSystem = true, + Ativo = true, CreatedAt = DateTime.UtcNow }; - - db.Tenants.Add(tenant); - await db.SaveChangesAsync(); + db.Tenants.Add(systemTenant); + } + else + { + systemTenant.NomeOficial = SystemTenantConstants.SystemTenantNomeOficial; + systemTenant.IsSystem = true; + systemTenant.Ativo = true; } - await NormalizeLegacyTenantDataAsync(db, tenant.Id); + await db.SaveChangesAsync(); - tenantProvider.SetTenantId(tenant.Id); + var emailFromEnv = Environment.GetEnvironmentVariable("SYSADMIN_EMAIL") + ?? Environment.GetEnvironmentVariable("ADMIN_MASTER_EMAIL"); + var passwordFromEnv = Environment.GetEnvironmentVariable("SYSADMIN_PASSWORD") + ?? Environment.GetEnvironmentVariable("ADMIN_MASTER_PASSWORD"); - var normalizedEmail = userManager.NormalizeEmail(options.AdminEmail); - var existingAdmin = await userManager.Users - .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenant.Id); + var adminMasterEmail = (emailFromEnv ?? options.AdminMasterEmail ?? string.Empty).Trim().ToLowerInvariant(); + var adminMasterPassword = passwordFromEnv ?? options.AdminMasterPassword ?? string.Empty; - if (existingAdmin == null) + if (string.IsNullOrWhiteSpace(adminMasterEmail) || string.IsNullOrWhiteSpace(adminMasterPassword)) { - var adminUser = new ApplicationUser - { - UserName = options.AdminEmail, - Email = options.AdminEmail, - Name = options.AdminName, - TenantId = tenant.Id, - EmailConfirmed = true, - IsActive = true, - LockoutEnabled = true - }; - - var createResult = await userManager.CreateAsync(adminUser, options.AdminPassword); - if (createResult.Succeeded) - { - await userManager.AddToRoleAsync(adminUser, "admin"); - } + throw new InvalidOperationException( + "Credenciais do sysadmin ausentes. Defina SYSADMIN_EMAIL e SYSADMIN_PASSWORD (ou Seed:AdminMasterEmail/Seed:AdminMasterPassword)."); } - else if (options.ReapplyAdminCredentialsOnStartup) + + var normalizedEmail = userManager.NormalizeEmail(adminMasterEmail); + + var previousTenant = tenantProvider.TenantId; + tenantProvider.SetTenantId(systemTenantId); + + try { - existingAdmin.Name = options.AdminName; - existingAdmin.Email = options.AdminEmail; - existingAdmin.UserName = options.AdminEmail; - existingAdmin.EmailConfirmed = true; - existingAdmin.IsActive = true; - existingAdmin.LockoutEnabled = true; + var existingAdminMaster = await userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.TenantId == systemTenantId && u.NormalizedEmail == normalizedEmail); - await userManager.SetLockoutEndDateAsync(existingAdmin, null); - await userManager.ResetAccessFailedCountAsync(existingAdmin); - await userManager.UpdateAsync(existingAdmin); - - var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdmin); - var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdmin, resetToken, options.AdminPassword); - if (!resetPasswordResult.Succeeded) + if (existingAdminMaster == null) { - var removePasswordResult = await userManager.RemovePasswordAsync(existingAdmin); - if (removePasswordResult.Succeeded) + var adminMaster = new ApplicationUser { - await userManager.AddPasswordAsync(existingAdmin, options.AdminPassword); + Name = options.AdminMasterName, + Email = adminMasterEmail, + UserName = adminMasterEmail, + TenantId = systemTenantId, + EmailConfirmed = true, + IsActive = true, + LockoutEnabled = true + }; + + var createResult = await userManager.CreateAsync(adminMaster, adminMasterPassword); + EnsureIdentitySucceeded(createResult, "Falha ao criar usuário sysadmin."); + + var addRoleResult = await userManager.AddToRoleAsync(adminMaster, SystemTenantConstants.SystemRole); + EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial."); + } + else + { + existingAdminMaster.Name = options.AdminMasterName; + existingAdminMaster.Email = adminMasterEmail; + existingAdminMaster.UserName = adminMasterEmail; + existingAdminMaster.EmailConfirmed = true; + existingAdminMaster.IsActive = true; + existingAdminMaster.LockoutEnabled = true; + + var updateResult = await userManager.UpdateAsync(existingAdminMaster); + EnsureIdentitySucceeded(updateResult, "Falha ao atualizar usuário sysadmin."); + + if (options.ReapplyAdminCredentialsOnStartup) + { + await userManager.SetLockoutEndDateAsync(existingAdminMaster, null); + await userManager.ResetAccessFailedCountAsync(existingAdminMaster); + + var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdminMaster); + var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdminMaster, resetToken, adminMasterPassword); + if (!resetPasswordResult.Succeeded) + { + var removePasswordResult = await userManager.RemovePasswordAsync(existingAdminMaster); + if (removePasswordResult.Succeeded) + { + var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword); + EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin."); + } + else + { + var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword); + EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin."); + } + } + } + + if (!await userManager.IsInRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole)) + { + var addRoleResult = await userManager.AddToRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole); + EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial."); } } + } + finally + { + tenantProvider.SetTenantId(previousTenant); + } + } - if (!await userManager.IsInRoleAsync(existingAdmin, "admin")) + private static void EnsureIdentitySucceeded(IdentityResult result, string message) + { + if (result.Succeeded) + { + return; + } + + var details = string.Join("; ", result.Errors.Select(e => e.Description)); + throw new InvalidOperationException($"{message} Detalhes: {details}"); + } + + private static async Task MigrateLegacyRolesAsync(AppDbContext db, RoleManager> roleManager) + { + await MigrateLegacyRoleAsync(db, roleManager, "admin_master", AppRoles.SysAdmin); + await MigrateLegacyRoleAsync(db, roleManager, "admin", AppRoles.SysAdmin); + await MigrateLegacyRoleAsync(db, roleManager, "leitura", AppRoles.Cliente); + await MigrateLegacyRoleAsync(db, roleManager, "operador", AppRoles.Cliente); + } + + private static async Task MigrateLegacyRoleAsync( + AppDbContext db, + RoleManager> roleManager, + string legacyRole, + string newRole) + { + var legacyRoleId = await roleManager.Roles + .Where(r => r.Name == legacyRole) + .Select(r => (Guid?)r.Id) + .FirstOrDefaultAsync(); + if (!legacyRoleId.HasValue) + { + return; + } + + var newRoleId = await roleManager.Roles + .Where(r => r.Name == newRole) + .Select(r => (Guid?)r.Id) + .FirstOrDefaultAsync(); + if (!newRoleId.HasValue) + { + return; + } + + var legacyUserIds = await db.UserRoles + .Where(ur => ur.RoleId == legacyRoleId.Value) + .Select(ur => ur.UserId) + .Distinct() + .ToListAsync(); + if (legacyUserIds.Count == 0) + { + return; + } + + var alreadyInNewRole = await db.UserRoles + .Where(ur => ur.RoleId == newRoleId.Value && legacyUserIds.Contains(ur.UserId)) + .Select(ur => ur.UserId) + .ToListAsync(); + var existingSet = alreadyInNewRole.ToHashSet(); + + foreach (var userId in legacyUserIds) + { + if (!existingSet.Contains(userId)) { - await userManager.AddToRoleAsync(existingAdmin, "admin"); + db.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = newRoleId.Value + }); } } - tenantProvider.SetTenantId(null); - } + var legacyAssignments = await db.UserRoles + .Where(ur => ur.RoleId == legacyRoleId.Value) + .ToListAsync(); + if (legacyAssignments.Count > 0) + { + db.UserRoles.RemoveRange(legacyAssignments); + } - private static async Task NormalizeLegacyTenantDataAsync(AppDbContext db, Guid defaultTenantId) - { - if (defaultTenantId == Guid.Empty) - return; + await db.SaveChangesAsync(); - await db.Users - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.MobileLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.MuregLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.BillingClients - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.UserDatas - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.VigenciaLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.TrocaNumeroLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ChipVirgemLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ControleRecebidoLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.Notifications - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoMacrophonyPlans - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoMacrophonyTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoVivoLineResumos - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoVivoLineTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoClienteEspeciais - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoPlanoContratoResumos - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoPlanoContratoTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoLineTotais - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoReservaLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoReservaTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ParcelamentoLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ParcelamentoMonthValues - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.AuditLogs - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ImportAuditRuns - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ImportAuditIssues - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); + var legacyRoleStillUsed = await db.UserRoles.AnyAsync(ur => ur.RoleId == legacyRoleId.Value); + if (!legacyRoleStillUsed) + { + var legacyRoleEntity = await roleManager.Roles.FirstOrDefaultAsync(r => r.Id == legacyRoleId.Value); + if (legacyRoleEntity != null) + { + await roleManager.DeleteAsync(legacyRoleEntity); + } + } } } diff --git a/Dtos/SystemTenantDtos.cs b/Dtos/SystemTenantDtos.cs new file mode 100644 index 0000000..723d9ef --- /dev/null +++ b/Dtos/SystemTenantDtos.cs @@ -0,0 +1,23 @@ +namespace line_gestao_api.Dtos; + +public class SystemTenantListItemDto +{ + public Guid TenantId { get; set; } + public string NomeOficial { get; set; } = string.Empty; +} + +public class CreateSystemTenantUserRequest +{ + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public List Roles { get; set; } = new(); +} + +public class SystemTenantUserCreatedDto +{ + public Guid UserId { get; set; } + public Guid TenantId { get; set; } + public string Email { get; set; } = string.Empty; + public IReadOnlyList Roles { get; set; } = Array.Empty(); +} diff --git a/Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs b/Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs new file mode 100644 index 0000000..7818b96 --- /dev/null +++ b/Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130000_CreateTenantsAndAuditLogsSystemContracts")] + public partial class CreateTenantsAndAuditLogsSystemContracts : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'Name' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'NomeOficial' + ) THEN + ALTER TABLE "Tenants" RENAME COLUMN "Name" TO "NomeOficial"; + END IF; + END + $$; + """); + + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "IsSystem" boolean NOT NULL DEFAULT FALSE;"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "Ativo" boolean NOT NULL DEFAULT TRUE;"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "SourceType" character varying(80) NULL;"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "SourceKey" character varying(300) NULL;"""); + migrationBuilder.Sql(""" + UPDATE "Tenants" + SET "NomeOficial" = COALESCE(NULLIF("NomeOficial", ''), 'TENANT_SEM_NOME') + WHERE "NomeOficial" IS NULL OR "NomeOficial" = ''; + """); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ALTER COLUMN "NomeOficial" SET NOT NULL;"""); + migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Tenants_SourceType_SourceKey" ON "Tenants" ("SourceType", "SourceKey");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Tenants_IsSystem_Ativo" ON "Tenants" ("IsSystem", "Ativo");"""); + + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "ActorUserId" uuid NULL;"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "ActorTenantId" uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "TargetTenantId" uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "MetadataJson" jsonb NOT NULL DEFAULT '{}'::jsonb;"""); + + migrationBuilder.Sql(""" + UPDATE "AuditLogs" + SET "ActorUserId" = COALESCE("ActorUserId", "UserId"), + "ActorTenantId" = CASE + WHEN "ActorTenantId" = '00000000-0000-0000-0000-000000000000' THEN "TenantId" + ELSE "ActorTenantId" + END, + "TargetTenantId" = CASE + WHEN "TargetTenantId" = '00000000-0000-0000-0000-000000000000' THEN "TenantId" + ELSE "TargetTenantId" + END, + "MetadataJson" = COALESCE("MetadataJson", '{}'::jsonb); + """); + + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_ActorTenantId" ON "AuditLogs" ("ActorTenantId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_TargetTenantId" ON "AuditLogs" ("TargetTenantId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_ActorUserId" ON "AuditLogs" ("ActorUserId");"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_ActorUserId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_TargetTenantId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_ActorTenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "MetadataJson";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "TargetTenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "ActorTenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "ActorUserId";"""); + + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Tenants_IsSystem_Ativo";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Tenants_SourceType_SourceKey";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "SourceKey";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "SourceType";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "Ativo";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "IsSystem";"""); + + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'NomeOficial' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'Name' + ) THEN + ALTER TABLE "Tenants" RENAME COLUMN "NomeOficial" TO "Name"; + END IF; + END + $$; + """); + } + } +} diff --git a/Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs b/Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs new file mode 100644 index 0000000..ebc27b8 --- /dev/null +++ b/Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130100_AddTenantIdToMobileLinesIfNeeded")] + public partial class AddTenantIdToMobileLinesIfNeeded : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'MobileLines' + AND column_name = 'TenantId' + ) THEN + ALTER TABLE "MobileLines" ADD COLUMN "TenantId" uuid NULL; + END IF; + END + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op intencional para evitar perda de dados em bancos legados. + } + } +} diff --git a/Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs b/Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs new file mode 100644 index 0000000..01d76a0 --- /dev/null +++ b/Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130200_BackfillTenantsFromDistinctMobileLinesCliente")] + public partial class BackfillTenantsFromDistinctMobileLinesCliente : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "MobileLines" + WHERE "Cliente" IS NULL OR btrim("Cliente") = '' + ) THEN + RAISE EXCEPTION 'Backfill abortado: MobileLines.Cliente possui valores NULL/vazios. Corrija os dados antes de migrar.'; + END IF; + END + $$; + """); + + migrationBuilder.Sql("""CREATE EXTENSION IF NOT EXISTS pgcrypto;"""); + + migrationBuilder.Sql(""" + INSERT INTO "Tenants" ( + "Id", + "NomeOficial", + "IsSystem", + "Ativo", + "SourceType", + "SourceKey", + "CreatedAt" + ) + SELECT + gen_random_uuid(), + src."Cliente", + FALSE, + TRUE, + 'MobileLines.Cliente', + src."Cliente", + NOW() + FROM ( + SELECT DISTINCT "Cliente" + FROM "MobileLines" + ) src + LEFT JOIN "Tenants" t + ON t."SourceType" = 'MobileLines.Cliente' + AND t."SourceKey" = src."Cliente" + WHERE t."Id" IS NULL; + """); + + migrationBuilder.Sql(""" + UPDATE "Tenants" + SET "NomeOficial" = "SourceKey", + "IsSystem" = FALSE, + "Ativo" = TRUE + WHERE "SourceType" = 'MobileLines.Cliente' + AND "SourceKey" IS NOT NULL; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op intencional. Evita remover tenants já em uso. + } + } +} diff --git a/Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs b/Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs new file mode 100644 index 0000000..8bb48f1 --- /dev/null +++ b/Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey")] + public partial class BackfillMobileLinesTenantIdFromTenantSourceKey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + UPDATE "MobileLines" m + SET "TenantId" = t."Id" + FROM "Tenants" t + WHERE t."SourceType" = 'MobileLines.Cliente' + AND t."SourceKey" = m."Cliente" + AND (m."TenantId" IS NULL OR m."TenantId" <> t."Id"); + """); + + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "MobileLines" m + LEFT JOIN "Tenants" t + ON t."SourceType" = 'MobileLines.Cliente' + AND t."SourceKey" = m."Cliente" + WHERE t."Id" IS NULL + ) THEN + RAISE EXCEPTION 'Backfill abortado: existem MobileLines sem tenant correspondente por SourceKey exato.'; + END IF; + END + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op intencional. + } + } +} diff --git a/Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs b/Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs new file mode 100644 index 0000000..d5097b3 --- /dev/null +++ b/Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes")] + public partial class MakeMobileLinesTenantIdNotNullAndIndexes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" DROP DEFAULT;"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" SET NOT NULL;"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_TenantId" ON "MobileLines" ("TenantId");"""); + migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_MobileLines_TenantId_Linha" ON "MobileLines" ("TenantId", "Linha");"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_TenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" DROP NOT NULL;"""); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 2b375cf..6d4fcfb 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -245,6 +245,12 @@ namespace line_gestao_api.Migrations .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ActorTenantId") + .HasColumnType("uuid"); + b.Property("ChangesJson") .IsRequired() .HasColumnType("jsonb"); @@ -274,6 +280,10 @@ namespace line_gestao_api.Migrations .HasMaxLength(80) .HasColumnType("character varying(80)"); + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb"); + b.Property("RequestMethod") .HasMaxLength(10) .HasColumnType("character varying(10)"); @@ -285,6 +295,9 @@ namespace line_gestao_api.Migrations b.Property("TenantId") .HasColumnType("uuid"); + b.Property("TargetTenantId") + .HasColumnType("uuid"); + b.Property("UserEmail") .HasMaxLength(200) .HasColumnType("character varying(200)"); @@ -298,6 +311,10 @@ namespace line_gestao_api.Migrations b.HasKey("Id"); + b.HasIndex("ActorUserId"); + + b.HasIndex("ActorTenantId"); + b.HasIndex("EntityName"); b.HasIndex("OccurredAtUtc"); @@ -306,6 +323,8 @@ namespace line_gestao_api.Migrations b.HasIndex("TenantId"); + b.HasIndex("TargetTenantId"); + b.HasIndex("UserId"); b.ToTable("AuditLogs"); @@ -1357,15 +1376,35 @@ namespace line_gestao_api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Ativo") + .HasColumnType("boolean"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Name") + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("NomeOficial") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SourceType") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); b.HasKey("Id"); + b.HasIndex("IsSystem", "Ativo"); + + b.HasIndex("SourceType", "SourceKey") + .IsUnique(); + b.ToTable("Tenants"); }); diff --git a/Models/AuditLog.cs b/Models/AuditLog.cs index 2584ff5..1995a7f 100644 --- a/Models/AuditLog.cs +++ b/Models/AuditLog.cs @@ -6,10 +6,16 @@ public class AuditLog : ITenantEntity { public Guid Id { get; set; } = Guid.NewGuid(); + // Compatibilidade com histórico atual + filtro global. public Guid TenantId { get; set; } + public Guid? ActorUserId { get; set; } + public Guid ActorTenantId { get; set; } + public Guid TargetTenantId { get; set; } + public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow; + // Campos legados usados pela tela de histórico. public Guid? UserId { get; set; } public string? UserName { get; set; } public string? UserEmail { get; set; } @@ -21,6 +27,7 @@ public class AuditLog : ITenantEntity public string? EntityLabel { get; set; } public string ChangesJson { get; set; } = "[]"; + public string MetadataJson { get; set; } = "{}"; public string? RequestPath { get; set; } public string? RequestMethod { get; set; } diff --git a/Models/Tenant.cs b/Models/Tenant.cs index 7b70ab7..51542e5 100644 --- a/Models/Tenant.cs +++ b/Models/Tenant.cs @@ -3,6 +3,10 @@ namespace line_gestao_api.Models; public class Tenant { public Guid Id { get; set; } = Guid.NewGuid(); - public string Name { get; set; } = string.Empty; + public string NomeOficial { get; set; } = string.Empty; + public bool IsSystem { get; set; } + public bool Ativo { get; set; } = true; + public string? SourceType { get; set; } + public string? SourceKey { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Program.cs b/Program.cs index 5ad43ac..beec0cf 100644 --- a/Program.cs +++ b/Program.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Security.Claims; using System.Text; using System.Threading.RateLimiting; using line_gestao_api.Data; @@ -91,6 +92,7 @@ builder.Services.AddDbContext(options => builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -144,7 +146,13 @@ builder.Services }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("SystemAdmin", policy => + { + policy.RequireRole(SystemTenantConstants.SystemRole); + }); +}); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; @@ -194,3 +202,7 @@ app.MapControllers(); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.Run(); + +public partial class Program +{ +} diff --git a/Services/AppRoles.cs b/Services/AppRoles.cs new file mode 100644 index 0000000..6bc0ff5 --- /dev/null +++ b/Services/AppRoles.cs @@ -0,0 +1,10 @@ +namespace line_gestao_api.Services; + +public static class AppRoles +{ + public const string SysAdmin = "sysadmin"; + public const string Gestor = "gestor"; + public const string Cliente = "cliente"; + + public static readonly string[] All = [SysAdmin, Gestor, Cliente]; +} diff --git a/Services/AuditLogBuilder.cs b/Services/AuditLogBuilder.cs index 3cfac9a..d427e4e 100644 --- a/Services/AuditLogBuilder.cs +++ b/Services/AuditLogBuilder.cs @@ -67,7 +67,7 @@ public class AuditLogBuilder : IAuditLogBuilder public List BuildAuditLogs(ChangeTracker changeTracker) { - var tenantId = _tenantProvider.TenantId; + var tenantId = _tenantProvider.ActorTenantId; if (tenantId == null) { return new List(); @@ -88,6 +88,12 @@ public class AuditLogBuilder : IAuditLogBuilder return new List(); } + if (IsSystemRequest(requestPath)) + { + // Endpoints system usam auditoria explicita com actor/target. + return new List(); + } + var logs = new List(); foreach (var entry in changeTracker.Entries()) @@ -109,6 +115,9 @@ public class AuditLogBuilder : IAuditLogBuilder logs.Add(new AuditLog { TenantId = tenantId.Value, + ActorUserId = userInfo.UserId, + ActorTenantId = tenantId.Value, + TargetTenantId = tenantId.Value, OccurredAtUtc = DateTime.UtcNow, UserId = userInfo.UserId, UserName = userInfo.UserName, @@ -119,6 +128,7 @@ public class AuditLogBuilder : IAuditLogBuilder EntityId = BuildEntityId(entry), EntityLabel = BuildEntityLabel(entry), ChangesJson = JsonSerializer.Serialize(changes, JsonOptions), + MetadataJson = "{}", RequestPath = requestPath, RequestMethod = requestMethod, IpAddress = ipAddress @@ -138,6 +148,16 @@ public class AuditLogBuilder : IAuditLogBuilder return requestPath.Contains("/import-excel", StringComparison.OrdinalIgnoreCase); } + private static bool IsSystemRequest(string? requestPath) + { + if (string.IsNullOrWhiteSpace(requestPath)) + { + return false; + } + + return requestPath.StartsWith("/api/system", StringComparison.OrdinalIgnoreCase); + } + private static string ResolveAction(EntityState state) => state switch { diff --git a/Services/DeterministicGuid.cs b/Services/DeterministicGuid.cs new file mode 100644 index 0000000..26dc405 --- /dev/null +++ b/Services/DeterministicGuid.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace line_gestao_api.Services; + +public static class DeterministicGuid +{ + public static Guid FromString(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + throw new ArgumentException("Valor obrigatório para gerar Guid determinístico.", nameof(input)); + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + Span bytes = stackalloc byte[16]; + hash.AsSpan(0, 16).CopyTo(bytes); + return new Guid(bytes); + } +} diff --git a/Services/ISystemAuditService.cs b/Services/ISystemAuditService.cs new file mode 100644 index 0000000..1d3f41d --- /dev/null +++ b/Services/ISystemAuditService.cs @@ -0,0 +1,6 @@ +namespace line_gestao_api.Services; + +public interface ISystemAuditService +{ + Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default); +} diff --git a/Services/ITenantProvider.cs b/Services/ITenantProvider.cs index f32efc7..7434de8 100644 --- a/Services/ITenantProvider.cs +++ b/Services/ITenantProvider.cs @@ -2,6 +2,8 @@ namespace line_gestao_api.Services; public interface ITenantProvider { + Guid? ActorTenantId { get; } Guid? TenantId { get; } + bool HasGlobalViewAccess { get; } void SetTenantId(Guid? tenantId); } diff --git a/Services/SystemAuditActions.cs b/Services/SystemAuditActions.cs new file mode 100644 index 0000000..a0fde25 --- /dev/null +++ b/Services/SystemAuditActions.cs @@ -0,0 +1,8 @@ +namespace line_gestao_api.Services; + +public static class SystemAuditActions +{ + public const string ListTenants = "SYSTEM_LIST_TENANTS"; + public const string CreateTenantUser = "SYS_CREATE_USER"; + public const string CreateTenantUserRejected = "SYS_CREATE_USER_ERR"; +} diff --git a/Services/SystemAuditService.cs b/Services/SystemAuditService.cs new file mode 100644 index 0000000..e22be4b --- /dev/null +++ b/Services/SystemAuditService.cs @@ -0,0 +1,115 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using line_gestao_api.Data; +using line_gestao_api.Models; + +namespace line_gestao_api.Services; + +public class SystemAuditService : ISystemAuditService +{ + private const int ActionMaxLength = 20; + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITenantProvider _tenantProvider; + + public SystemAuditService( + AppDbContext db, + IHttpContextAccessor httpContextAccessor, + ITenantProvider tenantProvider) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + _tenantProvider = tenantProvider; + } + + public async Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default) + { + var actorTenantId = _tenantProvider.ActorTenantId; + if (!actorTenantId.HasValue || actorTenantId.Value == Guid.Empty) + { + return; + } + + var user = _httpContextAccessor.HttpContext?.User; + var userId = ResolveUserId(user); + var userName = ResolveUserName(user); + var userEmail = ResolveUserEmail(user); + + var request = _httpContextAccessor.HttpContext?.Request; + var requestPath = request?.Path.Value; + var requestMethod = request?.Method; + var ipAddress = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString(); + var safeMetadataJson = JsonSerializer.Serialize(metadata ?? new { }, JsonOptions); + var normalizedAction = NormalizeAction(action); + + _db.AuditLogs.Add(new AuditLog + { + TenantId = actorTenantId.Value, + ActorUserId = userId, + ActorTenantId = actorTenantId.Value, + TargetTenantId = targetTenantId, + OccurredAtUtc = DateTime.UtcNow, + Action = normalizedAction, + Page = "System", + EntityName = "System", + EntityId = targetTenantId.ToString(), + EntityLabel = null, + ChangesJson = "[]", + MetadataJson = safeMetadataJson, + UserId = userId, + UserName = userName, + UserEmail = userEmail, + RequestPath = requestPath, + RequestMethod = requestMethod, + IpAddress = ipAddress + }); + + await _db.SaveChangesAsync(cancellationToken); + } + + private static string NormalizeAction(string? action) + { + var normalized = (action ?? string.Empty).Trim().ToUpperInvariant(); + if (string.IsNullOrEmpty(normalized)) + { + return "UNKNOWN"; + } + + if (normalized.Length <= ActionMaxLength) + { + return normalized; + } + + return normalized[..ActionMaxLength]; + } + + private static Guid? ResolveUserId(ClaimsPrincipal? user) + { + var raw = user?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user?.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? user?.FindFirstValue("sub"); + + return Guid.TryParse(raw, out var parsed) ? parsed : null; + } + + private static string? ResolveUserName(ClaimsPrincipal? user) + { + return user?.FindFirstValue("name") + ?? user?.FindFirstValue(ClaimTypes.Name) + ?? user?.Identity?.Name; + } + + private static string? ResolveUserEmail(ClaimsPrincipal? user) + { + return user?.FindFirstValue(ClaimTypes.Email) + ?? user?.FindFirstValue(JwtRegisteredClaimNames.Email) + ?? user?.FindFirstValue("email"); + } +} diff --git a/Services/SystemTenantConstants.cs b/Services/SystemTenantConstants.cs new file mode 100644 index 0000000..e468c09 --- /dev/null +++ b/Services/SystemTenantConstants.cs @@ -0,0 +1,11 @@ +namespace line_gestao_api.Services; + +public static class SystemTenantConstants +{ + public const string SystemTenantSeed = "SYSTEM_TENANT"; + public const string SystemTenantNomeOficial = "SystemTenant"; + public const string SystemRole = AppRoles.SysAdmin; + public const string MobileLinesClienteSourceType = "MobileLines.Cliente"; + + public static readonly Guid SystemTenantId = DeterministicGuid.FromString(SystemTenantSeed); +} diff --git a/Services/TenantProvider.cs b/Services/TenantProvider.cs index 1cf34e5..2e030f4 100644 --- a/Services/TenantProvider.cs +++ b/Services/TenantProvider.cs @@ -13,8 +13,14 @@ public class TenantProvider : ITenantProvider _httpContextAccessor = httpContextAccessor; } + public Guid? ActorTenantId => TenantId; + public Guid? TenantId => CurrentTenant.Value ?? ResolveFromClaims(); + public bool HasGlobalViewAccess => + HasRole(AppRoles.SysAdmin) || + HasRole(AppRoles.Gestor); + public void SetTenantId(Guid? tenantId) { CurrentTenant.Value = tenantId; @@ -27,4 +33,21 @@ public class TenantProvider : ITenantProvider return Guid.TryParse(claim, out var tenantId) ? tenantId : null; } + + private bool HasRole(string role) + { + var principal = _httpContextAccessor.HttpContext?.User; + if (principal?.Identity?.IsAuthenticated != true) + { + return false; + } + + var roleClaims = principal.FindAll(ClaimTypes.Role) + .Select(c => c.Value) + .Concat(principal.FindAll("role").Select(c => c.Value)) + .Concat(principal.FindAll("roles").Select(c => c.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + return roleClaims.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/Services/VigenciaNotificationBackgroundService.cs b/Services/VigenciaNotificationBackgroundService.cs index b6e5d9e..4491e5e 100644 --- a/Services/VigenciaNotificationBackgroundService.cs +++ b/Services/VigenciaNotificationBackgroundService.cs @@ -49,7 +49,10 @@ public class VigenciaNotificationBackgroundService : BackgroundService return; } - var tenants = await db.Tenants.AsNoTracking().ToListAsync(stoppingToken); + var tenants = await db.Tenants + .AsNoTracking() + .Where(t => !t.IsSystem && t.Ativo) + .ToListAsync(stoppingToken); if (tenants.Count == 0) { _logger.LogWarning("Nenhum tenant encontrado para gerar notificações."); diff --git a/appsettings.Development.json b/appsettings.Development.json index ea2b463..22d0f54 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -31,9 +31,8 @@ "Seed": { "Enabled": true, "ReapplyAdminCredentialsOnStartup": true, - "DefaultTenantName": "Default", - "AdminName": "Administrador", - "AdminEmail": "admin@linegestao.local", - "AdminPassword": "DevAdmin123!" + "AdminMasterName": "Admin Master", + "AdminMasterEmail": "admin.master@linegestao.local", + "AdminMasterPassword": "DevAdminMaster123!" } } diff --git a/appsettings.Local.example.json b/appsettings.Local.example.json index b3c813f..6405fe3 100644 --- a/appsettings.Local.example.json +++ b/appsettings.Local.example.json @@ -11,9 +11,8 @@ "Seed": { "Enabled": true, "ReapplyAdminCredentialsOnStartup": false, - "DefaultTenantName": "Default", - "AdminName": "Administrador", - "AdminEmail": "admin@linegestao.local", - "AdminPassword": "YOUR_LOCAL_ADMIN_SEED_PASSWORD" + "AdminMasterName": "Admin Master", + "AdminMasterEmail": "admin.master@linegestao.local", + "AdminMasterPassword": "YOUR_LOCAL_ADMIN_MASTER_SEED_PASSWORD" } } diff --git a/appsettings.json b/appsettings.json index bc7a8bb..2d9078c 100644 --- a/appsettings.json +++ b/appsettings.json @@ -31,9 +31,8 @@ "Seed": { "Enabled": true, "ReapplyAdminCredentialsOnStartup": true, - "DefaultTenantName": "Default", - "AdminName": "Administrador", - "AdminEmail": "admin@linegestao.local", - "AdminPassword": "DevAdmin123!" + "AdminMasterName": "Admin Master", + "AdminMasterEmail": "admin.master@linegestao.local", + "AdminMasterPassword": "DevAdminMaster123!" } -} +} diff --git a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs index 1ff2f1b..e50457d 100644 --- a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs +++ b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs @@ -148,8 +148,12 @@ namespace line_gestao_api.Tests TenantId = tenantId; } + public Guid? ActorTenantId => TenantId; + public Guid? TenantId { get; private set; } + public bool HasGlobalViewAccess => false; + public void SetTenantId(Guid? tenantId) { TenantId = tenantId; diff --git a/line-gestao-api.Tests/SystemTenantIntegrationTests.cs b/line-gestao-api.Tests/SystemTenantIntegrationTests.cs new file mode 100644 index 0000000..aec2461 --- /dev/null +++ b/line-gestao-api.Tests/SystemTenantIntegrationTests.cs @@ -0,0 +1,335 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Models; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace line_gestao_api.Tests; + +public class SystemTenantIntegrationTests +{ + private static readonly Guid TenantAId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid TenantBId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private const string TenantAClientName = "CLIENTE-ALFA LTDA"; + private const string TenantBClientName = "CLIENTE-BETA S/A"; + + [Fact] + public async Task CommonUser_OnlySeesOwnTenantData() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + await UpsertUserAsync(factory.Services, TenantAId, "tenanta.user@test.local", "TenantA123!", "cliente"); + + var token = await LoginAndGetTokenAsync(client, "tenanta.user@test.local", "TenantA123!", TenantAId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/api/lines/clients"); + response.EnsureSuccessStatusCode(); + + var clients = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(clients); + Assert.Contains(TenantAClientName, clients!); + Assert.DoesNotContain(TenantBClientName, clients); + } + + [Fact] + public async Task CommonUser_CannotAccessSystemEndpoints() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + await UpsertUserAsync(factory.Services, TenantAId, "tenanta.block@test.local", "TenantA123!", "cliente"); + + var token = await LoginAndGetTokenAsync(client, "tenanta.block@test.local", "TenantA123!", TenantAId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/api/system/tenants?source=MobileLines.Cliente&active=true"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysAdmin_CanListClientTenants() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + + var token = await LoginAndGetTokenAsync( + client, + "admin.master@test.local", + "AdminMaster123!", + SystemTenantConstants.SystemTenantId); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await client.GetAsync("/api/system/tenants?source=MobileLines.Cliente&active=true"); + response.EnsureSuccessStatusCode(); + + var tenants = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(tenants); + Assert.Contains(tenants!, t => t.TenantId == TenantAId && t.NomeOficial == TenantAClientName); + Assert.Contains(tenants, t => t.TenantId == TenantBId && t.NomeOficial == TenantBClientName); + } + + [Fact] + public async Task SysAdmin_CreatesTenantUser_AndNewUserSeesOnlyOwnTenant() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + + var adminToken = await LoginAndGetTokenAsync( + client, + "admin.master@test.local", + "AdminMaster123!", + SystemTenantConstants.SystemTenantId); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + var request = new CreateSystemTenantUserRequest + { + Name = "Usuário Cliente A", + Email = "novo.clientea@test.local", + Password = "ClienteA123!", + Roles = new List { "cliente" } + }; + + var createResponse = await client.PostAsJsonAsync($"/api/system/tenants/{TenantAId}/users", request); + createResponse.EnsureSuccessStatusCode(); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal(TenantAId, created!.TenantId); + Assert.Equal("novo.clientea@test.local", created.Email); + Assert.Contains("cliente", created.Roles); + + var userToken = await LoginAndGetTokenAsync(client, "novo.clientea@test.local", "ClienteA123!", TenantAId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + + var visibleClientsResponse = await client.GetAsync("/api/lines/clients"); + visibleClientsResponse.EnsureSuccessStatusCode(); + var clients = await visibleClientsResponse.Content.ReadFromJsonAsync>(); + + Assert.NotNull(clients); + Assert.Contains(TenantAClientName, clients!); + Assert.DoesNotContain(TenantBClientName, clients); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var systemAudit = await db.AuditLogs + .IgnoreQueryFilters() + .OrderByDescending(x => x.OccurredAtUtc) + .FirstOrDefaultAsync(x => x.Action == SystemAuditActions.CreateTenantUser); + + Assert.NotNull(systemAudit); + Assert.Equal(SystemTenantConstants.SystemTenantId, systemAudit!.ActorTenantId); + Assert.Equal(TenantAId, systemAudit.TargetTenantId); + Assert.DoesNotContain("ClienteA123!", systemAudit.MetadataJson); + } + + private static async Task SeedTenantsAndLinesAsync(IServiceProvider services) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var tenantA = await db.Tenants.FirstOrDefaultAsync(t => t.Id == TenantAId); + if (tenantA == null) + { + db.Tenants.Add(new Tenant + { + Id = TenantAId, + NomeOficial = TenantAClientName, + IsSystem = false, + Ativo = true, + SourceType = SystemTenantConstants.MobileLinesClienteSourceType, + SourceKey = TenantAClientName, + CreatedAt = DateTime.UtcNow + }); + } + + var tenantB = await db.Tenants.FirstOrDefaultAsync(t => t.Id == TenantBId); + if (tenantB == null) + { + db.Tenants.Add(new Tenant + { + Id = TenantBId, + NomeOficial = TenantBClientName, + IsSystem = false, + Ativo = true, + SourceType = SystemTenantConstants.MobileLinesClienteSourceType, + SourceKey = TenantBClientName, + CreatedAt = DateTime.UtcNow + }); + } + + var currentLines = await db.MobileLines.IgnoreQueryFilters().ToListAsync(); + if (currentLines.Count > 0) + { + db.MobileLines.RemoveRange(currentLines); + } + + db.MobileLines.AddRange( + new MobileLine + { + Id = Guid.NewGuid(), + Item = 1, + Linha = "5511999990001", + Cliente = TenantAClientName, + TenantId = TenantAId + }, + new MobileLine + { + Id = Guid.NewGuid(), + Item = 2, + Linha = "5511888880002", + Cliente = TenantBClientName, + TenantId = TenantBId + }); + + await db.SaveChangesAsync(); + } + + private static async Task UpsertUserAsync( + IServiceProvider services, + Guid tenantId, + string email, + string password, + params string[] roles) + { + await using var scope = services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var tenantProvider = scope.ServiceProvider.GetRequiredService(); + + var previousTenant = tenantProvider.ActorTenantId; + tenantProvider.SetTenantId(tenantId); + + try + { + var normalizedEmail = userManager.NormalizeEmail(email); + var user = await userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail); + + if (user == null) + { + user = new ApplicationUser + { + Name = email, + Email = email, + UserName = email, + TenantId = tenantId, + EmailConfirmed = true, + IsActive = true, + LockoutEnabled = true + }; + + var createResult = await userManager.CreateAsync(user, password); + Assert.True(createResult.Succeeded, string.Join("; ", createResult.Errors.Select(e => e.Description))); + } + else + { + var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + var reset = await userManager.ResetPasswordAsync(user, resetToken, password); + Assert.True(reset.Succeeded, string.Join("; ", reset.Errors.Select(e => e.Description))); + } + + var existingRoles = await userManager.GetRolesAsync(user); + if (existingRoles.Count > 0) + { + var removeRolesResult = await userManager.RemoveFromRolesAsync(user, existingRoles); + Assert.True(removeRolesResult.Succeeded, string.Join("; ", removeRolesResult.Errors.Select(e => e.Description))); + } + + var addRolesResult = await userManager.AddToRolesAsync(user, roles); + Assert.True(addRolesResult.Succeeded, string.Join("; ", addRolesResult.Errors.Select(e => e.Description))); + } + finally + { + tenantProvider.SetTenantId(previousTenant); + } + } + + private static async Task LoginAndGetTokenAsync(HttpClient client, string email, string password, Guid tenantId) + { + var previousAuth = client.DefaultRequestHeaders.Authorization; + client.DefaultRequestHeaders.Authorization = null; + + try + { + var response = await client.PostAsJsonAsync("/auth/login", new + { + email, + password, + tenantId + }); + + response.EnsureSuccessStatusCode(); + var auth = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(auth); + Assert.False(string.IsNullOrWhiteSpace(auth!.Token)); + return auth.Token; + } + finally + { + client.DefaultRequestHeaders.Authorization = previousAuth; + } + } + + private sealed class ApiFactory : WebApplicationFactory + { + private readonly string _databaseName = $"line-gestao-tests-{Guid.NewGuid()}"; + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["App:UseHttpsRedirection"] = "false", + ["Seed:Enabled"] = "true", + ["Seed:AdminMasterName"] = "Admin Master", + ["Seed:AdminMasterEmail"] = "admin.master@test.local", + ["Seed:AdminMasterPassword"] = "AdminMaster123!", + ["Seed:ReapplyAdminCredentialsOnStartup"] = "true" + }); + }); + + builder.ConfigureServices(services => + { + var notificationHostedService = services + .Where(d => d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(VigenciaNotificationBackgroundService)) + .ToList(); + foreach (var descriptor in notificationHostedService) + { + services.Remove(descriptor); + } + + services.RemoveAll(); + services.RemoveAll>(); + + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_databaseName); + }); + }); + } + } +} diff --git a/line-gestao-api.Tests/line-gestao-api.Tests.csproj b/line-gestao-api.Tests/line-gestao-api.Tests.csproj index c799d69..109e103 100644 --- a/line-gestao-api.Tests/line-gestao-api.Tests.csproj +++ b/line-gestao-api.Tests/line-gestao-api.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/postman/SystemTenant-MultiTenant-Tests.postman_collection.json b/postman/SystemTenant-MultiTenant-Tests.postman_collection.json new file mode 100644 index 0000000..4a627d6 --- /dev/null +++ b/postman/SystemTenant-MultiTenant-Tests.postman_collection.json @@ -0,0 +1,395 @@ +{ + "info": { + "name": "Line Gestao - SystemTenant Multi-tenant Tests", + "_postman_id": "c4c0b7d9-7f11-4a0c-b8ca-332633f12601", + "description": "Fluxo de testes para sysadmin, endpoints /api/system/* e isolamento por tenant.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "adminMasterToken", "value": "" }, + { "key": "tenantAUserToken", "value": "" }, + { "key": "newTenantAUserToken", "value": "" }, + { "key": "tenantAUserId", "value": "" }, + { "key": "newTenantAUserId", "value": "" }, + { "key": "newTenantAUserEmail", "value": "" }, + { "key": "newTenantAUserPassword", "value": "" }, + { "key": "newTenantAUserName", "value": "" } + ], + "item": [ + { + "name": "1) Login sysadmin", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{adminMasterEmail}}\",\n \"password\": \"{{adminMasterPassword}}\",\n \"tenantId\": \"{{systemTenantId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const json = pm.response.json();", + "pm.test('Retorna token JWT', function () {", + " pm.expect(json.token).to.be.a('string').and.not.empty;", + "});", + "pm.collectionVariables.set('adminMasterToken', json.token);" + ] + } + } + ] + }, + { + "name": "2) GET /api/system/tenants (sysadmin)", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{adminMasterToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/system/tenants?source=MobileLines.Cliente&active=true", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants"], + "query": [ + { "key": "source", "value": "MobileLines.Cliente" }, + { "key": "active", "value": "true" } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const tenants = pm.response.json();", + "pm.test('Retorna array de tenants', function () {", + " pm.expect(Array.isArray(tenants)).to.eql(true);", + "});", + "const tenantAClientName = pm.environment.get('tenantAClientName');", + "const tenantBClientName = pm.environment.get('tenantBClientName');", + "if (tenantAClientName) {", + " const tenantA = tenants.find(t => t.nomeOficial === tenantAClientName || t.NomeOficial === tenantAClientName);", + " pm.test('Tenant A encontrado por nomeOficial', function () {", + " pm.expect(tenantA).to.exist;", + " });", + " if (tenantA && (tenantA.tenantId || tenantA.TenantId)) {", + " pm.environment.set('tenantAId', tenantA.tenantId || tenantA.TenantId);", + " }", + "}", + "if (tenantBClientName) {", + " const tenantB = tenants.find(t => t.nomeOficial === tenantBClientName || t.NomeOficial === tenantBClientName);", + " pm.test('Tenant B encontrado por nomeOficial', function () {", + " pm.expect(tenantB).to.exist;", + " });", + " if (tenantB && (tenantB.tenantId || tenantB.TenantId)) {", + " pm.environment.set('tenantBId', tenantB.tenantId || tenantB.TenantId);", + " }", + "}" + ] + } + } + ] + }, + { + "name": "3) POST /api/system/tenants/{tenantId}/users (criar usuário comum tenant A)", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{adminMasterToken}}", "type": "string" } + ] + }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"{{tenantAUserName}}\",\n \"email\": \"{{tenantAUserEmail}}\",\n \"password\": \"{{tenantAUserPassword}}\",\n \"roles\": [\"cliente\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/system/tenants/{{tenantAId}}/users", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants", "{{tenantAId}}", "users"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 201 (criado) ou 409 (já existe)', function () {", + " pm.expect([201, 409]).to.include(pm.response.code);", + "});", + "if (pm.response.code === 201) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('tenantAUserId', json.userId || json.UserId || '');", + "}" + ] + } + } + ] + }, + { + "name": "4) Login usuário comum tenant A", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{tenantAUserEmail}}\",\n \"password\": \"{{tenantAUserPassword}}\",\n \"tenantId\": \"{{tenantAId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const json = pm.response.json();", + "pm.collectionVariables.set('tenantAUserToken', json.token);" + ] + } + } + ] + }, + { + "name": "5) Usuário comum NÃO acessa /api/system/*", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{tenantAUserToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/system/tenants?source=MobileLines.Cliente&active=true", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants"], + "query": [ + { "key": "source", "value": "MobileLines.Cliente" }, + { "key": "active", "value": "true" } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 403 Forbidden', function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ] + }, + { + "name": "6) Usuário comum tenant A vê apenas seu tenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{tenantAUserToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/lines/clients", + "host": ["{{baseUrl}}"], + "path": ["api", "lines", "clients"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const clients = pm.response.json();", + "pm.test('Retorna lista de clientes', function () {", + " pm.expect(Array.isArray(clients)).to.eql(true);", + "});", + "const tenantAClientName = pm.environment.get('tenantAClientName');", + "const tenantBClientName = pm.environment.get('tenantBClientName');", + "if (tenantAClientName) {", + " pm.test('Contém cliente do tenant A', function () {", + " pm.expect(clients).to.include(tenantAClientName);", + " });", + "}", + "if (tenantBClientName) {", + " pm.test('Não contém cliente do tenant B', function () {", + " pm.expect(clients).to.not.include(tenantBClientName);", + " });", + "}" + ] + } + } + ] + }, + { + "name": "7) POST /api/system/tenants/{tenantId}/users (novo usuário tenant A)", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const suffix = Date.now().toString().slice(-8);", + "pm.collectionVariables.set('newTenantAUserEmail', `novo.tenant.a.${suffix}@test.local`);", + "pm.collectionVariables.set('newTenantAUserPassword', 'ClienteA123!');", + "pm.collectionVariables.set('newTenantAUserName', `Novo Tenant A ${suffix}`);" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 201', function () {", + " pm.response.to.have.status(201);", + "});", + "const json = pm.response.json();", + "pm.collectionVariables.set('newTenantAUserId', json.userId || json.UserId || '');" + ] + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{adminMasterToken}}", "type": "string" } + ] + }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"{{newTenantAUserName}}\",\n \"email\": \"{{newTenantAUserEmail}}\",\n \"password\": \"{{newTenantAUserPassword}}\",\n \"roles\": [\"cliente\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/system/tenants/{{tenantAId}}/users", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants", "{{tenantAId}}", "users"] + } + } + }, + { + "name": "8) Login novo usuário tenant A", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{newTenantAUserEmail}}\",\n \"password\": \"{{newTenantAUserPassword}}\",\n \"tenantId\": \"{{tenantAId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const json = pm.response.json();", + "pm.collectionVariables.set('newTenantAUserToken', json.token);" + ] + } + } + ] + }, + { + "name": "9) Novo usuário tenant A vê apenas seu tenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{newTenantAUserToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/lines/clients", + "host": ["{{baseUrl}}"], + "path": ["api", "lines", "clients"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const clients = pm.response.json();", + "const tenantAClientName = pm.environment.get('tenantAClientName');", + "const tenantBClientName = pm.environment.get('tenantBClientName');", + "if (tenantAClientName) {", + " pm.test('Contém cliente do tenant A', function () {", + " pm.expect(clients).to.include(tenantAClientName);", + " });", + "}", + "if (tenantBClientName) {", + " pm.test('Não contém cliente do tenant B', function () {", + " pm.expect(clients).to.not.include(tenantBClientName);", + " });", + "}" + ] + } + } + ] + } + ] +} diff --git a/postman/SystemTenant-MultiTenant-Tests.postman_environment.json b/postman/SystemTenant-MultiTenant-Tests.postman_environment.json new file mode 100644 index 0000000..ebb2ee1 --- /dev/null +++ b/postman/SystemTenant-MultiTenant-Tests.postman_environment.json @@ -0,0 +1,64 @@ +{ + "id": "d1d8e905-e4b8-40c5-a62e-afb27c59b685", + "name": "Line Gestao - Local", + "values": [ + { + "key": "baseUrl", + "value": "http://localhost:5000", + "enabled": true + }, + { + "key": "systemTenantId", + "value": "562617c4-90dc-cfce-ddf4-64b6284dc4f2", + "enabled": true + }, + { + "key": "adminMasterEmail", + "value": "sysadmin@linegestao.local", + "enabled": true + }, + { + "key": "adminMasterPassword", + "value": "", + "enabled": true + }, + { + "key": "tenantAId", + "value": "", + "enabled": true + }, + { + "key": "tenantBId", + "value": "", + "enabled": true + }, + { + "key": "tenantAClientName", + "value": "", + "enabled": true + }, + { + "key": "tenantBClientName", + "value": "", + "enabled": true + }, + { + "key": "tenantAUserName", + "value": "Usuario Tenant A", + "enabled": true + }, + { + "key": "tenantAUserEmail", + "value": "tenanta.user@test.local", + "enabled": true + }, + { + "key": "tenantAUserPassword", + "value": "TenantA123!", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-02-26T12:00:00.000Z", + "_postman_exported_using": "Codex GPT-5" +}