Compare commits

...

8 Commits

Author SHA1 Message Date
Eduardo 9d7306c395 Feat: Aplicando Alterações/Ajustes 2026-03-02 13:27:07 -03:00
Leon Nascimento Moreira 1f255888b0
Merge pull request #26 from eduardolopesx03/adicao-linhas-lote
Adicao linhas lote
2026-02-27 17:07:32 -03:00
Eduardo 024b7d299d Merge branch 'dev' into adicao-linhas-lote 2026-02-27 16:58:41 -03:00
Eduardo 242f8bc707 Feat: Corrigindo merge 2026-02-27 16:54:20 -03:00
Eduardo 7a7b5db73e Feat: Adição Lote de Linhas 2026-02-27 14:55:05 -03:00
Leon Nascimento Moreira 8f0fa83b78
Merge pull request #25 from eduardolopesx03/line-gestao-clientUsers
feat: criação da gestão de usuários por tenant
2026-02-26 17:22:39 -03:00
Leon 0d51d39c5c feat: criação da gestão de usuários por tenant 2026-02-26 17:11:44 -03:00
Eduardo 0ab7fa955f Adição Lote de Linhas 2026-02-25 11:33:20 -03:00
57 changed files with 4361 additions and 342 deletions

3
.gitignore vendored
View File

@ -6,6 +6,9 @@
# dotenv files
.env
appsettings.Local.json
appsettings*.json
line-gestao-api.csproj
line-gestao-api.http
# User-specific files
*.rsuser

View File

@ -1,4 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using line_gestao_api.Data;
@ -22,21 +23,24 @@ public class AuthController : ControllerBase
private readonly AppDbContext _db;
private readonly ITenantProvider _tenantProvider;
private readonly IConfiguration _config;
private readonly ILogger<AuthController> _logger;
public AuthController(
UserManager<ApplicationUser> userManager,
AppDbContext db,
ITenantProvider tenantProvider,
IConfiguration config)
IConfiguration config,
ILogger<AuthController> logger)
{
_userManager = userManager;
_db = db;
_tenantProvider = tenantProvider;
_config = config;
_logger = logger;
}
[HttpPost("register")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Register(RegisterRequest req)
{
if (req.Password != req.ConfirmPassword)
@ -69,12 +73,14 @@ 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)
return Unauthorized("Tenant inválido.");
await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value);
var token = await GenerateJwtAsync(user, effectiveTenantId.Value);
return Ok(new AuthResponse(token));
}
@ -141,6 +147,8 @@ public class AuthController : ControllerBase
if (!effectiveTenantId.HasValue)
return Unauthorized("Tenant inválido.");
await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value);
var token = await GenerateJwtAsync(user, effectiveTenantId.Value);
return Ok(new AuthResponse(token));
}
@ -192,23 +200,216 @@ public class AuthController : ControllerBase
private async Task<Guid?> 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;
}
private async Task EnsureClientTenantDataBoundAsync(ApplicationUser user, Guid tenantId)
{
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Any(r => string.Equals(r, AppRoles.Cliente, StringComparison.OrdinalIgnoreCase)))
{
return;
}
try
{
await RebindMobileLinesToTenantBySourceKeyAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falha ao sincronizar linhas para tenant {TenantId} durante login de cliente.", tenantId);
}
}
private async Task<int> RebindMobileLinesToTenantBySourceKeyAsync(Guid tenantId)
{
if (tenantId == Guid.Empty)
{
return 0;
}
var tenant = await _db.Tenants
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == tenantId && t.Ativo && !t.IsSystem);
if (tenant == null)
{
return 0;
}
if (!string.Equals(
tenant.SourceType,
SystemTenantConstants.MobileLinesClienteSourceType,
StringComparison.OrdinalIgnoreCase))
{
return 0;
}
var normalizedKeys = new HashSet<string>(StringComparer.Ordinal);
AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey);
AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial);
if (normalizedKeys.Count == 0)
{
return 0;
}
var candidates = await _db.MobileLines
.IgnoreQueryFilters()
.Where(x => x.TenantId != tenant.Id)
.Where(x => x.Cliente != null && x.Cliente != string.Empty)
.ToListAsync();
if (candidates.Count == 0)
{
return 0;
}
var hasAnyTenantLine = await _db.MobileLines
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(x => x.TenantId == tenant.Id);
var now = DateTime.UtcNow;
var reassigned = ReassignByMatcher(
candidates,
normalizedKeys,
tenant.Id,
now,
isRelaxedMatch: false);
if (reassigned == 0 && !hasAnyTenantLine)
{
reassigned = ReassignByMatcher(
candidates,
normalizedKeys,
tenant.Id,
now,
isRelaxedMatch: true);
}
if (reassigned > 0)
{
await _db.SaveChangesAsync();
}
return reassigned;
}
private static int ReassignByMatcher(
IReadOnlyList<MobileLine> candidates,
IReadOnlyCollection<string> normalizedKeys,
Guid tenantId,
DateTime now,
bool isRelaxedMatch)
{
if (normalizedKeys.Count == 0)
{
return 0;
}
var keys = isRelaxedMatch
? normalizedKeys.Where(k => k.Length >= 6).ToList()
: normalizedKeys.ToList();
if (keys.Count == 0)
{
return 0;
}
var reassigned = 0;
foreach (var line in candidates)
{
var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty);
if (string.IsNullOrWhiteSpace(normalizedClient) ||
string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal))
{
continue;
}
var matches = !isRelaxedMatch
? keys.Contains(normalizedClient, StringComparer.Ordinal)
: keys.Any(k =>
normalizedClient.Contains(k, StringComparison.Ordinal) ||
k.Contains(normalizedClient, StringComparison.Ordinal));
if (!matches)
{
continue;
}
line.TenantId = tenantId;
line.UpdatedAt = now;
reassigned++;
}
return reassigned;
}
private static void AddNormalizedTenantKey(ISet<string> keys, string? rawKey)
{
var normalized = NormalizeTenantKey(rawKey ?? string.Empty);
if (string.IsNullOrWhiteSpace(normalized))
{
return;
}
if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal))
{
return;
}
keys.Add(normalized);
}
private static string NormalizeTenantKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(normalized.Length);
var previousWasSpace = false;
foreach (var ch in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsWhiteSpace(ch))
{
if (!previousWasSpace)
{
sb.Append(' ');
previousWasSpace = true;
}
continue;
}
sb.Append(char.ToUpperInvariant(ch));
previousWasSpace = false;
}
return sb.ToString().Trim();
}
}

View File

@ -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;
@ -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) ||
@ -188,7 +197,7 @@ namespace line_gestao_api.Controllers
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBillingClientRequest req)
{
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);
@ -221,7 +230,7 @@ namespace line_gestao_api.Controllers
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Delete(Guid id)
{
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -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<ActionResult<ChipVirgemDetailDto>> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -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<ActionResult<ControleRecebidoDetailDto>> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -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;
@ -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));

File diff suppressed because it is too large Load Diff

View File

@ -187,7 +187,7 @@ namespace line_gestao_api.Controllers
}
[HttpPost]
[Authorize(Roles = "admin,gestor")]
[Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<MuregDetailDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> ImportExcel([FromForm] ImportExcelForm form)

View File

@ -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),

View File

@ -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<ActionResult<ParcelamentoDetailDto>> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id);

View File

@ -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 != "")

View File

@ -1,3 +1,5 @@
using System.Text.RegularExpressions;
using System.Linq;
using line_gestao_api.Data;
using line_gestao_api.Dtos;
using line_gestao_api.Services;
@ -12,6 +14,8 @@ namespace line_gestao_api.Controllers;
[Authorize]
public class ResumoController : ControllerBase
{
private static readonly Regex PlanGbRegex = new(@"(\d+(?:[.,]\d+)?)\s*(GB|MB)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly AppDbContext _db;
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
@ -23,6 +27,36 @@ public class ResumoController : ControllerBase
[HttpGet]
public async Task<ActionResult<ResumoResponseDto>> GetResumo()
{
var spreadsheetResponse = await BuildSpreadsheetResumoAsync();
var hasLiveLines = await _db.MobileLines.AsNoTracking().AnyAsync();
if (hasLiveLines)
{
var live = await BuildLiveResumoAsync();
spreadsheetResponse.MacrophonyPlans = live.MacrophonyPlans;
spreadsheetResponse.MacrophonyTotals = live.MacrophonyTotals;
spreadsheetResponse.VivoLineResumos = live.VivoLineResumos;
spreadsheetResponse.VivoLineTotals = live.VivoLineTotals;
spreadsheetResponse.PlanoContratoResumos = live.PlanoContratoResumos;
spreadsheetResponse.PlanoContratoTotal = live.PlanoContratoTotal;
spreadsheetResponse.LineTotais = live.LineTotais;
spreadsheetResponse.GbDistribuicao = live.GbDistribuicao;
spreadsheetResponse.GbDistribuicaoTotal = live.GbDistribuicaoTotal;
spreadsheetResponse.ReservaLines = live.ReservaLines;
spreadsheetResponse.ReservaPorDdd = live.ReservaPorDdd;
spreadsheetResponse.TotalGeralLinhasReserva = live.TotalGeralLinhasReserva;
spreadsheetResponse.ReservaTotal = live.ReservaTotal;
}
spreadsheetResponse.MacrophonyTotals ??= new ResumoMacrophonyTotalDto();
spreadsheetResponse.VivoLineTotals ??= new ResumoVivoLineTotalDto();
return Ok(spreadsheetResponse);
}
private async Task<ResumoResponseDto> BuildSpreadsheetResumoAsync()
{
var reservaLines = await _db.ResumoReservaLines.AsNoTracking()
.OrderBy(x => x.Ddd)
@ -177,6 +211,302 @@ public class ResumoController : ControllerBase
response.VivoLineTotals ??= new ResumoVivoLineTotalDto();
response.VivoLineTotals.QtdLinhasTotal = canonicalTotalLinhas;
return Ok(response);
return response;
}
private async Task<ResumoResponseDto> BuildLiveResumoAsync()
{
var allLines = _db.MobileLines.AsNoTracking();
var nonReservaLines = allLines.Where(x =>
!EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
var reservaLinesQuery = allLines.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
var planosAgg = await nonReservaLines
.GroupBy(x => (x.PlanoContrato ?? "").Trim())
.Select(g => new
{
Plano = g.Key,
TotalLinhas = g.Count(),
FranquiaMedia = g.Average(x => (decimal?)x.FranquiaVivo),
ValorTotal = g.Sum(x => x.ValorContratoVivo ?? 0m),
TravelCount = g.Count(x => (x.VivoTravelMundo ?? 0m) > 0m)
})
.ToListAsync();
var planoRows = planosAgg
.Select(row =>
{
var planoLabel = string.IsNullOrWhiteSpace(row.Plano) ? "SEM PLANO" : row.Plano;
var franquiaGb = (row.FranquiaMedia ?? 0m) > 0m
? row.FranquiaMedia
: ExtractGbFromPlanName(planoLabel);
var valorIndividual = row.TotalLinhas > 0 ? row.ValorTotal / row.TotalLinhas : (decimal?)null;
return new ResumoPlanoContratoResumoDto
{
PlanoContrato = planoLabel,
Gb = franquiaGb,
FranquiaGb = franquiaGb,
TotalLinhas = row.TotalLinhas,
ValorTotal = row.ValorTotal,
ValorIndividualComSvas = valorIndividual
};
})
.OrderByDescending(x => x.TotalLinhas ?? 0)
.ThenBy(x => x.PlanoContrato)
.ToList();
var macrophonyRows = planosAgg
.Select(row =>
{
var planoLabel = string.IsNullOrWhiteSpace(row.Plano) ? "SEM PLANO" : row.Plano;
var franquiaGb = (row.FranquiaMedia ?? 0m) > 0m
? row.FranquiaMedia
: ExtractGbFromPlanName(planoLabel);
var valorIndividual = row.TotalLinhas > 0 ? row.ValorTotal / row.TotalLinhas : (decimal?)null;
return new ResumoMacrophonyPlanDto
{
PlanoContrato = planoLabel,
Gb = franquiaGb,
FranquiaGb = franquiaGb,
TotalLinhas = row.TotalLinhas,
ValorTotal = row.ValorTotal,
ValorIndividualComSvas = valorIndividual,
VivoTravel = row.TravelCount > 0
};
})
.OrderByDescending(x => x.TotalLinhas ?? 0)
.ThenBy(x => x.PlanoContrato)
.ToList();
var clientesAgg = await nonReservaLines
.GroupBy(x => (x.Cliente ?? "").Trim())
.Select(g => new
{
Cliente = g.Key,
QtdLinhas = g.Count(),
FranquiaTotal = g.Sum(x => x.FranquiaVivo ?? 0m),
ValorContratoVivo = g.Sum(x => x.ValorContratoVivo ?? 0m),
FranquiaLine = g.Sum(x => x.FranquiaLine ?? 0m),
ValorContratoLine = g.Sum(x => x.ValorContratoLine ?? 0m),
Lucro = g.Sum(x => x.Lucro ?? 0m)
})
.ToListAsync();
var clientesRows = clientesAgg
.Select(row => new ResumoVivoLineResumoDto
{
Cliente = string.IsNullOrWhiteSpace(row.Cliente) ? "SEM CLIENTE" : row.Cliente,
QtdLinhas = row.QtdLinhas,
FranquiaTotal = row.FranquiaTotal,
ValorContratoVivo = row.ValorContratoVivo,
FranquiaLine = row.FranquiaLine,
ValorContratoLine = row.ValorContratoLine,
Lucro = row.Lucro
})
.OrderByDescending(x => x.QtdLinhas ?? 0)
.ThenBy(x => x.Cliente)
.ToList();
var totalLinhasNaoReserva = clientesRows.Sum(x => x.QtdLinhas ?? 0);
var totalFranquiaNaoReserva = clientesRows.Sum(x => x.FranquiaTotal ?? 0m);
var totalValorContratoVivo = clientesRows.Sum(x => x.ValorContratoVivo ?? 0m);
var totalFranquiaLine = clientesRows.Sum(x => x.FranquiaLine ?? 0m);
var totalValorContratoLine = clientesRows.Sum(x => x.ValorContratoLine ?? 0m);
var totalLucro = clientesRows.Sum(x => x.Lucro ?? 0m);
var pfAtivas = await nonReservaLines
.Where(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"))
.Where(x =>
EF.Functions.ILike((x.Skil ?? "").Trim(), "%fís%") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%fis%") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%pf%"))
.GroupBy(_ => 1)
.Select(g => new
{
QtdLinhas = g.Count(),
ValorTotal = g.Sum(x => x.ValorContratoLine ?? 0m),
LucroTotal = g.Sum(x => x.Lucro ?? 0m)
})
.FirstOrDefaultAsync();
var pjAtivas = await nonReservaLines
.Where(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"))
.Where(x =>
EF.Functions.ILike((x.Skil ?? "").Trim(), "%jur%") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%pj%"))
.GroupBy(_ => 1)
.Select(g => new
{
QtdLinhas = g.Count(),
ValorTotal = g.Sum(x => x.ValorContratoLine ?? 0m),
LucroTotal = g.Sum(x => x.Lucro ?? 0m)
})
.FirstOrDefaultAsync();
var totaisLine = new List<ResumoLineTotaisDto>
{
new()
{
Tipo = "PF",
QtdLinhas = pfAtivas?.QtdLinhas ?? 0,
ValorTotalLine = pfAtivas?.ValorTotal ?? 0m,
LucroTotalLine = pfAtivas?.LucroTotal ?? 0m
},
new()
{
Tipo = "PJ",
QtdLinhas = pjAtivas?.QtdLinhas ?? 0,
ValorTotalLine = pjAtivas?.ValorTotal ?? 0m,
LucroTotalLine = pjAtivas?.LucroTotal ?? 0m
},
new()
{
Tipo = "TOTAL",
QtdLinhas = totalLinhasNaoReserva,
ValorTotalLine = totalValorContratoLine,
LucroTotalLine = totalLucro
}
};
var gbDistribuicao = await nonReservaLines
.Where(x => (x.FranquiaVivo ?? 0m) > 0m)
.GroupBy(x => x.FranquiaVivo ?? 0m)
.Select(g => new ResumoGbDistribuicaoDto
{
Gb = g.Key,
Qtd = g.Count(),
Soma = g.Sum(x => x.FranquiaVivo ?? 0m)
})
.OrderBy(x => x.Gb)
.ToListAsync();
var gbDistribuicaoTotal = new ResumoGbDistribuicaoTotalDto
{
TotalLinhas = gbDistribuicao.Sum(x => x.Qtd ?? 0),
SomaTotal = gbDistribuicao.Sum(x => x.Soma ?? 0m)
};
var reservaSnapshot = await reservaLinesQuery
.Select(x => new
{
x.Linha,
x.FranquiaVivo,
x.ValorContratoVivo
})
.ToListAsync();
var reservaGroupRaw = reservaSnapshot
.Select(row => new
{
Ddd = ExtractDddFromLine(row.Linha) ?? "-",
FranquiaGb = row.FranquiaVivo
})
.GroupBy(x => new { x.Ddd, x.FranquiaGb })
.Select(g => new ResumoReservaLineDto
{
Ddd = g.Key.Ddd,
FranquiaGb = g.Key.FranquiaGb,
QtdLinhas = g.Count(),
Total = null
})
.OrderBy(x => x.Ddd)
.ThenBy(x => x.FranquiaGb ?? 0m)
.ToList();
var reservaPorDdd = reservaGroupRaw
.GroupBy(x => x.Ddd ?? "-")
.Select(g => new ResumoReservaPorDddDto
{
Ddd = g.Key,
TotalLinhas = g.Sum(x => x.QtdLinhas ?? 0),
PorFranquia = g
.GroupBy(x => x.FranquiaGb)
.Select(fg => new ResumoReservaPorFranquiaDto
{
FranquiaGb = fg.Key,
TotalLinhas = fg.Sum(x => x.QtdLinhas ?? 0)
})
.OrderBy(x => x.FranquiaGb ?? 0m)
.ToList()
})
.OrderBy(x => x.Ddd)
.ToList();
var reservaTotalLinhas = reservaSnapshot.Count;
var reservaTotalValor = reservaSnapshot.Sum(x => x.ValorContratoVivo ?? 0m);
return new ResumoResponseDto
{
MacrophonyPlans = macrophonyRows,
MacrophonyTotals = new ResumoMacrophonyTotalDto
{
TotalLinhasTotal = totalLinhasNaoReserva,
FranquiaGbTotal = totalFranquiaNaoReserva,
ValorTotal = totalValorContratoVivo
},
VivoLineResumos = clientesRows,
VivoLineTotals = new ResumoVivoLineTotalDto
{
QtdLinhasTotal = totalLinhasNaoReserva,
FranquiaTotal = totalFranquiaNaoReserva,
ValorContratoVivo = totalValorContratoVivo,
FranquiaLine = totalFranquiaLine,
ValorContratoLine = totalValorContratoLine,
Lucro = totalLucro
},
PlanoContratoResumos = planoRows,
PlanoContratoTotal = new ResumoPlanoContratoTotalDto
{
ValorTotal = planoRows.Sum(x => x.ValorTotal ?? 0m)
},
LineTotais = totaisLine,
GbDistribuicao = gbDistribuicao,
GbDistribuicaoTotal = gbDistribuicaoTotal,
ReservaLines = reservaGroupRaw,
ReservaPorDdd = reservaPorDdd,
TotalGeralLinhasReserva = reservaTotalLinhas,
ReservaTotal = new ResumoReservaTotalDto
{
QtdLinhasTotal = reservaTotalLinhas,
Total = reservaTotalValor
}
};
}
private static decimal? ExtractGbFromPlanName(string? planoContrato)
{
if (string.IsNullOrWhiteSpace(planoContrato))
return null;
var match = PlanGbRegex.Match(planoContrato);
if (!match.Success)
return null;
var normalized = match.Groups[1].Value.Replace(',', '.');
if (!decimal.TryParse(normalized, out var rawValue))
return null;
var unit = match.Groups[2].Value.ToUpperInvariant();
return unit == "MB" ? rawValue / 1000m : rawValue;
}
private static string? ExtractDddFromLine(string? linha)
{
if (string.IsNullOrWhiteSpace(linha))
return null;
var digits = new string(linha.Where(char.IsDigit).ToArray());
if (digits.Length >= 12 && digits.StartsWith("55"))
return digits.Substring(2, 2);
if (digits.Length >= 10)
return digits.Substring(0, 2);
return null;
}
}

View File

@ -0,0 +1,424 @@
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;
using System.Globalization;
using System.Text;
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<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole<Guid>> _roleManager;
private readonly ISystemAuditService _systemAuditService;
public SystemTenantUsersController(
AppDbContext db,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole<Guid>> roleManager,
ISystemAuditService systemAuditService)
{
_db = db;
_userManager = userManager;
_roleManager = roleManager;
_systemAuditService = systemAuditService;
}
[HttpPost]
public async Task<ActionResult<SystemTenantUserCreatedDto>> 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 (request.ClientCredentialsOnly)
{
if (tenant.IsSystem)
{
return await RejectAsync(
tenantId,
StatusCodes.Status400BadRequest,
"Credenciais de cliente não podem ser criadas no SystemTenant.",
"invalid_client_credentials_on_system_tenant");
}
if (normalizedRoles.Count != 1 || !normalizedRoles.Contains(AppRoles.Cliente, StringComparer.OrdinalIgnoreCase))
{
return await RejectAsync(
tenantId,
StatusCodes.Status400BadRequest,
"Neste fluxo, somente a role cliente é permitida.",
"invalid_roles_for_client_credentials_flow");
}
}
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());
}
var linesReassigned = 0;
if (request.ClientCredentialsOnly)
{
linesReassigned = await RebindMobileLinesToTenantBySourceKeyAsync(tenant);
}
await _systemAuditService.LogAsync(
action: SystemAuditActions.CreateTenantUser,
targetTenantId: tenantId,
metadata: new
{
createdUserId = user.Id,
email,
roles = normalizedRoles,
linesReassigned
});
var response = new SystemTenantUserCreatedDto
{
UserId = user.Id,
TenantId = tenantId,
Email = email,
Roles = normalizedRoles
};
return StatusCode(StatusCodes.Status201Created, response);
}
private async Task<ActionResult<SystemTenantUserCreatedDto>> 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);
}
private async Task<int> RebindMobileLinesToTenantBySourceKeyAsync(Tenant tenant)
{
if (tenant.Id == Guid.Empty)
{
return 0;
}
if (!string.Equals(
tenant.SourceType,
SystemTenantConstants.MobileLinesClienteSourceType,
StringComparison.OrdinalIgnoreCase))
{
return 0;
}
var normalizedKeys = new HashSet<string>(StringComparer.Ordinal);
AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey);
AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial);
if (normalizedKeys.Count == 0)
{
return 0;
}
var candidates = await _db.MobileLines
.IgnoreQueryFilters()
.Where(x => x.TenantId != tenant.Id)
.Where(x => x.Cliente != null && x.Cliente != string.Empty)
.ToListAsync();
if (candidates.Count == 0)
{
return 0;
}
var hasAnyTenantLine = await _db.MobileLines
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(x => x.TenantId == tenant.Id);
var now = DateTime.UtcNow;
var reassigned = ReassignByMatcher(
candidates,
normalizedKeys,
tenant.Id,
now,
isRelaxedMatch: false);
if (reassigned == 0 && !hasAnyTenantLine)
{
reassigned = ReassignByMatcher(
candidates,
normalizedKeys,
tenant.Id,
now,
isRelaxedMatch: true);
}
if (reassigned > 0)
{
await _db.SaveChangesAsync();
}
return reassigned;
}
private static int ReassignByMatcher(
IReadOnlyList<MobileLine> candidates,
IReadOnlyCollection<string> normalizedKeys,
Guid tenantId,
DateTime now,
bool isRelaxedMatch)
{
if (normalizedKeys.Count == 0)
{
return 0;
}
var keys = isRelaxedMatch
? normalizedKeys.Where(k => k.Length >= 6).ToList()
: normalizedKeys.ToList();
if (keys.Count == 0)
{
return 0;
}
var reassigned = 0;
foreach (var line in candidates)
{
var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty);
if (string.IsNullOrWhiteSpace(normalizedClient) ||
string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal))
{
continue;
}
var matches = !isRelaxedMatch
? keys.Contains(normalizedClient, StringComparer.Ordinal)
: keys.Any(k =>
normalizedClient.Contains(k, StringComparison.Ordinal) ||
k.Contains(normalizedClient, StringComparison.Ordinal));
if (!matches)
{
continue;
}
line.TenantId = tenantId;
line.UpdatedAt = now;
reassigned++;
}
return reassigned;
}
private static void AddNormalizedTenantKey(ISet<string> keys, string? rawKey)
{
var normalized = NormalizeTenantKey(rawKey ?? string.Empty);
if (string.IsNullOrWhiteSpace(normalized))
{
return;
}
if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal))
{
return;
}
keys.Add(normalized);
}
private static string NormalizeTenantKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(normalized.Length);
var previousWasSpace = false;
foreach (var ch in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsWhiteSpace(ch))
{
if (!previousWasSpace)
{
sb.Append(' ');
previousWasSpace = true;
}
continue;
}
sb.Append(char.ToUpperInvariant(ch));
previousWasSpace = false;
}
return sb.ToString().Trim();
}
}

View File

@ -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<ActionResult<IReadOnlyList<SystemTenantListItemDto>>> 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);
}
}

View File

@ -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 = "sysadmin,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");
}
}
}

View File

@ -112,7 +112,7 @@ namespace line_gestao_api.Controllers
// ✅ CREATE
// ==========================================================
[HttpPost]
[Authorize(Roles = "admin,gestor")]
[Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<TrocaNumeroDetailDto>> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -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}%") ||
@ -261,7 +263,7 @@ namespace line_gestao_api.Controllers
}
[HttpPost]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<ActionResult<UserDataDetailDto>> Create([FromBody] CreateUserDataRequest req)
{
var now = DateTime.UtcNow;
@ -363,7 +365,7 @@ namespace line_gestao_api.Controllers
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserDataRequest req)
{
var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id);
@ -395,7 +397,7 @@ namespace line_gestao_api.Controllers
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id)
{
var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -17,8 +17,9 @@ public class UsersController : ControllerBase
{
private static readonly HashSet<string> 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<ActionResult<UserListItemDto>> 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<ActionResult<PagedResult<UserListItemDto>>> GetAll(
[FromQuery] string? search,
[FromQuery] string? permissao,
@ -132,7 +133,9 @@ public class UsersController : ControllerBase
page = page < 1 ? 1 : page;
pageSize = pageSize < 1 ? 20 : pageSize;
var usersQuery = _userManager.Users.AsNoTracking();
var usersQuery = _userManager.Users
.IgnoreQueryFilters()
.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
@ -191,10 +194,13 @@ public class UsersController : ControllerBase
}
[HttpGet("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<ActionResult<UserListItemDto>> GetById(Guid id)
{
var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
var user = await _userManager.Users
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
return NotFound();
@ -215,7 +221,7 @@ public class UsersController : ControllerBase
}
[HttpPatch("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UserUpdateRequest req)
{
var errors = await ValidateUpdateAsync(id, req);
@ -224,7 +230,9 @@ public class UsersController : ControllerBase
return BadRequest(new ValidationErrorResponse { Errors = errors });
}
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id);
var user = await _userManager.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
return NotFound();
@ -295,14 +303,9 @@ public class UsersController : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id)
{
if (_tenantProvider.TenantId == null)
{
return Unauthorized();
}
var currentUserId = GetCurrentUserId();
if (currentUserId.HasValue && currentUserId.Value == id)
{
@ -315,12 +318,14 @@ public class UsersController : ControllerBase
});
}
var tenantId = _tenantProvider.TenantId.Value;
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id && u.TenantId == tenantId);
var user = await _userManager.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
return NotFound();
}
var tenantId = user.TenantId;
if (user.IsActive)
{
@ -334,12 +339,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 +365,7 @@ public class UsersController : ControllerBase
{
Errors = new List<ValidationErrorDto>
{
new() { Field = "usuario", Message = "Não é permitido excluir o último administrador." }
new() { Field = "usuario", Message = "Não é permitido excluir o último sysadmin." }
}
});
}
@ -422,6 +427,10 @@ public class UsersController : ControllerBase
private async Task<List<ValidationErrorDto>> ValidateUpdateAsync(Guid userId, UserUpdateRequest req)
{
var errors = new List<ValidationErrorDto>();
var targetUser = await _userManager.Users
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == userId);
if (!string.IsNullOrWhiteSpace(req.Nome) && req.Nome.Trim().Length < 2)
{
@ -433,22 +442,22 @@ public class UsersController : ControllerBase
var email = req.Email.Trim().ToLowerInvariant();
var normalized = _userManager.NormalizeEmail(email);
var tenantId = _tenantProvider.TenantId;
if (tenantId == null)
if (targetUser == null)
{
errors.Add(new ValidationErrorDto { Field = "email", Message = "Tenant inválido." });
return errors;
}
else
{
var exists = await _userManager.Users.AnyAsync(u =>
u.Id != userId &&
u.TenantId == tenantId &&
u.NormalizedEmail == normalized);
if (exists)
{
errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." });
}
var tenantId = targetUser.TenantId;
var exists = await _userManager.Users
.IgnoreQueryFilters()
.AnyAsync(u =>
u.Id != userId &&
u.TenantId == tenantId &&
u.NormalizedEmail == normalized);
if (exists)
{
errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." });
}
}

View File

@ -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
@ -246,7 +256,7 @@ namespace line_gestao_api.Controllers
}
[HttpPost]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<ActionResult<VigenciaLineDetailDto>> Create([FromBody] CreateVigenciaRequest req)
{
var now = DateTime.UtcNow;
@ -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
@ -340,12 +354,15 @@ namespace line_gestao_api.Controllers
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admin")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVigenciaRequest req)
{
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,8 +389,41 @@ namespace line_gestao_api.Controllers
return NoContent();
}
[HttpPost("{id:guid}/renew")]
public async Task<IActionResult> 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")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id)
{
var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == 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 "";

View File

@ -78,6 +78,15 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Tenant>(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)
// =========================
@ -170,6 +179,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.HasIndex(x => x.Cliente);
e.HasIndex(x => x.Linha);
e.HasIndex(x => x.DtTerminoFidelizacao);
e.HasIndex(x => x.AutoRenewReferenceEndDate);
e.HasIndex(x => x.TenantId);
});
@ -270,6 +280,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
// =========================
modelBuilder.Entity<AuditLog>(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);
@ -281,8 +292,13 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.Property(x => 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);
@ -318,33 +334,33 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<TrocaNumeroLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoVivoLineTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoClienteEspecial>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoPlanoContratoResumo>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoPlanoContratoTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoLineTotais>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoGbDistribuicao>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoGbDistribuicaoTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoReservaLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ResumoReservaTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ParcelamentoLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ParcelamentoMonthValue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<TrocaNumeroLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoVivoLineTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoClienteEspecial>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoPlanoContratoResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoPlanoContratoTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoLineTotais>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoGbDistribuicao>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoGbDistribuicaoTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoReservaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoReservaTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ParcelamentoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ParcelamentoMonthValue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
}
public override int SaveChanges()
@ -363,12 +379,12 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
private void ApplyTenantIds()
{
if (_tenantProvider.TenantId == null)
if (_tenantProvider.ActorTenantId == null)
{
return;
}
var tenantId = _tenantProvider.TenantId.Value;
var tenantId = _tenantProvider.ActorTenantId.Value;
foreach (var entry in ChangeTracker.Entries<ITenantEntity>().Where(e => e.State == EntityState.Added))
{
if (entry.Entity.TenantId == Guid.Empty)

View File

@ -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<ITenantProvider>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<SeedOptions>>().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<Guid>(role));
var roleResult = await roleManager.CreateAsync(new IdentityRole<Guid>(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<IdentityRole<Guid>> 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<IdentityRole<Guid>> 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<Guid>
{
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);
}
}
}
}

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace line_gestao_api.Dtos
{
public class CreateMobileLinesBatchRequestDto
{
public List<CreateMobileLineDto> Lines { get; set; } = new();
}
public class CreateMobileLinesBatchResultDto
{
public int Created { get; set; }
public List<CreateMobileLinesBatchCreatedItemDto> Items { get; set; } = new();
}
public class CreateMobileLinesBatchCreatedItemDto
{
public Guid Id { get; set; }
public int Item { get; set; }
public string? Linha { get; set; }
public string? Cliente { get; set; }
}
}

View File

@ -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<LinesBatchExcelIssueDto> HeaderErrors { get; set; } = new();
public List<LinesBatchExcelIssueDto> HeaderWarnings { get; set; } = new();
public List<LinesBatchExcelPreviewRowDto> 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<LinesBatchExcelIssueDto> Errors { get; set; } = new();
public List<LinesBatchExcelIssueDto> 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<Guid> LineIds { get; set; } = new();
}
public sealed class MoveLinesToReservaRequestDto
{
public List<Guid> 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<AssignReservaLineItemResultDto> 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;
}
}

View File

@ -14,6 +14,7 @@
public string? Skil { get; set; }
public string? Modalidade { get; set; }
public string? VencConta { get; set; }
public decimal? FranquiaLine { get; set; }
// Campos para filtro deterministico de adicionais no frontend
public decimal? GestaoVozDados { get; set; }

24
Dtos/SystemTenantDtos.cs Normal file
View File

@ -0,0 +1,24 @@
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<string> Roles { get; set; } = new();
public bool ClientCredentialsOnly { get; set; }
}
public class SystemTenantUserCreatedDto
{
public Guid UserId { get; set; }
public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty;
public IReadOnlyList<string> Roles { get; set; } = Array.Empty<string>();
}

View File

@ -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; } = "";

View File

@ -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
$$;
""");
}
}
}

View File

@ -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.
}
}
}

View File

@ -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.
}
}
}

View File

@ -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.
}
}
}

View File

@ -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;""");
}
}
}

View File

@ -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<DateTime>(
name: "AutoRenewConfiguredAt",
table: "VigenciaLines",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AutoRenewReferenceEndDate",
table: "VigenciaLines",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AutoRenewYears",
table: "VigenciaLines",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
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");
}
}
}

View File

@ -245,6 +245,12 @@ namespace line_gestao_api.Migrations
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<Guid>("ActorTenantId")
.HasColumnType("uuid");
b.Property<string>("ChangesJson")
.IsRequired()
.HasColumnType("jsonb");
@ -274,6 +280,10 @@ namespace line_gestao_api.Migrations
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("MetadataJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("RequestMethod")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
@ -285,6 +295,9 @@ namespace line_gestao_api.Migrations
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<Guid>("TargetTenantId")
.HasColumnType("uuid");
b.Property<string>("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<bool>("Ativo")
.HasColumnType("boolean");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("NomeOficial")
.IsRequired()
.HasColumnType("text");
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SourceKey")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("SourceType")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.HasKey("Id");
b.HasIndex("IsSystem", "Ativo");
b.HasIndex("SourceType", "SourceKey")
.IsUnique();
b.ToTable("Tenants");
});
@ -1510,6 +1549,15 @@ namespace line_gestao_api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AutoRenewConfiguredAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("AutoRenewReferenceEndDate")
.HasColumnType("timestamp with time zone");
b.Property<int?>("AutoRenewYears")
.HasColumnType("integer");
b.Property<string>("Cliente")
.HasColumnType("text");
@ -1528,6 +1576,9 @@ namespace line_gestao_api.Migrations
b.Property<int>("Item")
.HasColumnType("integer");
b.Property<DateTime?>("LastAutoRenewedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Linha")
.HasColumnType("text");
@ -1550,6 +1601,8 @@ namespace line_gestao_api.Migrations
b.HasIndex("Cliente");
b.HasIndex("AutoRenewReferenceEndDate");
b.HasIndex("DtTerminoFidelizacao");
b.HasIndex("Item");

View File

@ -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; }

View File

@ -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;
}

View File

@ -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; }

View File

@ -1,4 +1,5 @@
using System.IO;
using System.Security.Claims;
using System.Text;
using System.Threading.RateLimiting;
using line_gestao_api.Data;
@ -91,9 +92,11 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddScoped<IAuditLogBuilder, AuditLogBuilder>();
builder.Services.AddScoped<ISystemAuditService, SystemAuditService>();
builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>();
builder.Services.AddScoped<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>();
builder.Services.AddScoped<GeralSpreadsheetTemplateService>();
builder.Services.AddScoped<SpreadsheetImportAuditService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
@ -143,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;
@ -193,3 +202,7 @@ app.MapControllers();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();
public partial class Program
{
}

10
Services/AppRoles.cs Normal file
View File

@ -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];
}

View File

@ -67,7 +67,7 @@ public class AuditLogBuilder : IAuditLogBuilder
public List<AuditLog> BuildAuditLogs(ChangeTracker changeTracker)
{
var tenantId = _tenantProvider.TenantId;
var tenantId = _tenantProvider.ActorTenantId;
if (tenantId == null)
{
return new List<AuditLog>();
@ -88,6 +88,12 @@ public class AuditLogBuilder : IAuditLogBuilder
return new List<AuditLog>();
}
if (IsSystemRequest(requestPath))
{
// Endpoints system usam auditoria explicita com actor/target.
return new List<AuditLog>();
}
var logs = new List<AuditLog>();
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
{

View File

@ -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<byte> bytes = stackalloc byte[16];
hash.AsSpan(0, 16).CopyTo(bytes);
return new Guid(bytes);
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,6 @@
namespace line_gestao_api.Services;
public interface ISystemAuditService
{
Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default);
}

View File

@ -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);
}

View File

@ -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";
}

View File

@ -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");
}
}

View File

@ -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);
}

View File

@ -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));
}
}

View File

@ -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.");

View File

@ -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<string, Guid> userByName,
IReadOnlyDictionary<string, Guid> 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<Notification>();
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<VigenciaLine> 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<string, Guid> userByName,
IReadOnlyDictionary<string, Guid> 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}";

View File

@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=linegestao_app;Password=1231234512Ed@"
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=postgres;Password=postgres"
},
"Jwt": {
"Key": "linegestao-local-jwt-key-2026-dev-strong-123456789",
@ -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!"
}
}

View File

@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=linegestao_app;Password=1231234512Ed@"
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=postgres;Password=postgres"
},
"Jwt": {
"Key": "YOUR_LOCAL_STRONG_JWT_KEY_MIN_32_CHARS",
@ -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"
}
}

View File

@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=linegestao_app;Password=1231234512Ed@"
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=postgres;Password=postgres"
},
"Jwt": {
"Key": "linegestao-local-jwt-key-2026-dev-strong-123456789",
@ -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!"
}
}
}

View File

@ -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;

View File

@ -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<List<string>>();
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<List<SystemTenantListItemDto>>();
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<string> { "cliente" }
};
var createResponse = await client.PostAsJsonAsync($"/api/system/tenants/{TenantAId}/users", request);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SystemTenantUserCreatedDto>();
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<List<string>>();
Assert.NotNull(clients);
Assert.Contains(TenantAClientName, clients!);
Assert.DoesNotContain(TenantBClientName, clients);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<AppDbContext>();
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<UserManager<ApplicationUser>>();
var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
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<string> 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<AuthResponse>();
Assert.NotNull(auth);
Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
return auth.Token;
}
finally
{
client.DefaultRequestHeaders.Authorization = previousAuth;
}
}
private sealed class ApiFactory : WebApplicationFactory<Program>
{
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<string, string?>
{
["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<AppDbContext>();
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(_databaseName);
});
});
}
}
}

View File

@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />

View File

@ -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);",
" });",
"}"
]
}
}
]
}
]
}

View File

@ -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"
}