Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ Em terminal interativo, `run-local.ps1` encaminha automaticamente ao setup se o
3. recompile a solução depois de aplicar as correções dos diagnósticos reais;
4. reinicie os projetos necessários pelo perfil de vários projetos **ODCA local**.

Para diagnosticar DLLs bloqueadas, o comando abaixo lista e tenta encerrar normalmente
somente processos cuja linha de comando contém o caminho deste checkout e um projeto
ODCA. Ele não encerra outros processos `dotnet`; processos sem encerramento normal são
apenas informados. Use `-Force` somente depois de conferir a lista exibida:

```powershell
.\scripts\stop-local.ps1
.\scripts\stop-local.ps1 -Force
```

Validação específica no Windows: pare a depuração, execute o comando acima e confirme
o encerramento do Worker; compile; inicie API e Worker; encerre-os normalmente; então
compile novamente e confirme que `Odca.Application.dll` e `Odca.Configuration.dll`
não permanecem bloqueadas.

Se uma compilação normal ainda apontar artefatos inconsistentes, feche esses processos, remova apenas `bin` e `obj` dos projetos citados, restaure e recompile. Preserve `development-runtime.json`, chaves de Data Protection, uploads e dados do PostgreSQL.

Também é possível iniciar API e Web em terminais separados:
Expand Down
5 changes: 3 additions & 2 deletions scripts/run-local.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ $projects = @(
$processes = @()
try {
foreach ($project in $projects) {
# Relative project names keep Start-Process argument handling safe when the repository path contains spaces.
$arguments = @('run', '--project', $project.Path, '--launch-profile', $project.Profile)
# The absolute project path lets stop-local.ps1 prove that the process belongs to this checkout.
$projectPath = Join-Path $repositoryRoot $project.Path
$arguments = @('run', '--project', ('"{0}"' -f $projectPath), '--launch-profile', $project.Profile)
$processes += Start-Process dotnet -ArgumentList $arguments -WorkingDirectory $repositoryRoot -NoNewWindow -PassThru
Write-Host ("{0} iniciado (PID {1})." -f $project.Name, $processes[-1].Id)
}
Expand Down
43 changes: 43 additions & 0 deletions scripts/stop-local.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[CmdletBinding()]
param([switch]$Force)

$ErrorActionPreference = 'Stop'
$repositoryRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)).TrimEnd('\')
$escapedRoot = [Regex]::Escape($repositoryRoot)
$projectPattern = 'Odca\.(Api|Web|Worker)(\.csproj|\.dll|\.exe)?'

$processes = @(Get-CimInstance Win32_Process | Where-Object {
$_.CommandLine -and $_.CommandLine -match $escapedRoot -and $_.CommandLine -match $projectPattern
})

if ($processes.Count -eq 0) {
Write-Host "Nenhum processo ODCA comprovadamente pertencente a '$repositoryRoot' foi encontrado."
exit 0
}

Write-Host 'Processos deste checkout:'
$processes | Select-Object ProcessId, Name, CommandLine | Format-Table -Wrap

foreach ($candidate in $processes) {
$process = Get-Process -Id $candidate.ProcessId -ErrorAction SilentlyContinue
if ($null -eq $process) { continue }

if ($process.CloseMainWindow()) {
Write-Host "Solicitado encerramento normal ao PID $($process.Id)."
$null = $process.WaitForExit(5000)
}

if (-not $process.HasExited) {
if ($Force) {
Write-Warning "Encerrando à força o PID $($process.Id), conforme solicitado com -Force."
Stop-Process -Id $process.Id -Force
}
else {
Write-Warning "PID $($process.Id) não oferece encerramento normal. Pare a depuração/terminal ou repita com -Force."
}
}
}

if (-not $Force -and @($processes | Where-Object { Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue }).Count -gt 0) {
exit 2
}
40 changes: 35 additions & 5 deletions src/Odca.Api/Controllers/ContractImportsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using Odca.Application.Tenancy;
using Odca.Contracts.DocumentImports;

namespace Odca.Api.Controllers;

[ApiController]
[Authorize(Policy = "PasswordChanged")]
[Route("api/v1/organizations/{tenantId:guid}/contract-imports")]
public sealed class ContractImportsController(NpgsqlDataSource dataSource) : ControllerBase
public sealed class ContractImportsController(
NpgsqlDataSource dataSource,
IConfiguration configuration,
ILogger<ContractImportsController> logger) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List(Guid tenantId, [FromQuery] string? status, [FromQuery] Guid? requester,
Expand All @@ -26,10 +30,28 @@ public async Task<IActionResult> List(Guid tenantId, [FromQuery] string? status,
await using var connection = await dataSource.OpenConnectionAsync(ct);
if (!await Allowed(connection, actor.Value, tenantId, "tenant.imports.read", ct)) return Forbid();
await using var tx = await connection.BeginTransactionAsync(ct); await SetTenant(connection, tenantId, tx, ct);
var timeZoneId = await connection.ExecuteScalarAsync<string>(new CommandDefinition(
var timeZoneId = await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition(
"SELECT timezone FROM odca.tenants WHERE id=@tenantId",
new { tenantId }, tx, cancellationToken: ct));
var range = CreateUtcRange(from, to, timeZoneId);
(DateTimeOffset? FromInclusive, DateTimeOffset? ToExclusive) range;
try
{
var timeZone = TimeZonePolicy.Resolve(timeZoneId, configuration["TimeZone:DefaultId"]);
range = CreateUtcRange(from, to, timeZone);
}
catch (TimeZoneConfigurationException exception)
{
logger.LogError(exception, "Configuração de fuso indisponível para tenant={TenantId}; motivo={Reason}.", tenantId, exception.Error);
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable,
title: "A configuração de data da organização está indisponível.",
detail: "Contate o administrador para corrigir o fuso horário configurado.");
}
catch (InvalidOperationException exception)
{
logger.LogError(exception, "Limite local inválido para tenant={TenantId}.", tenantId);
return Problem(statusCode: StatusCodes.Status422UnprocessableEntity,
title: "O intervalo informado não pode ser convertido no fuso da organização.");
}
var args = new { tenantId, status, requester, actor = actor.Value, mine, awaitingReview, range.FromInclusive, range.ToExclusive };
const string filter = """
WHERE i.tenant_id=@tenantId
Expand All @@ -56,9 +78,11 @@ FROM odca.contract_imports i
internal static (DateTimeOffset? FromInclusive, DateTimeOffset? ToExclusive) CreateUtcRange(
DateOnly? from,
DateOnly? to,
string timeZoneId)
TimeZoneInfo timeZone)
{
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
if (from.HasValue && to.HasValue && from.Value > to.Value)
throw new ArgumentException("A data final deve ser igual ou posterior à data inicial.", nameof(to));

return (
from is null ? null : StartOfDayUtc(from.Value, timeZone),
to is null || to == DateOnly.MaxValue ? null : StartOfDayUtc(to.Value.AddDays(1), timeZone));
Expand All @@ -69,6 +93,12 @@ private static DateTimeOffset StartOfDayUtc(DateOnly date, TimeZoneInfo timeZone
var local = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified);
if (timeZone.IsInvalidTime(local))
throw new InvalidOperationException($"O início do dia {date:yyyy-MM-dd} não existe no fuso configurado.");
if (timeZone.IsAmbiguousTime(local))
{
// An inclusive start must choose the first instant bearing this local clock value.
var earliestOffset = timeZone.GetAmbiguousTimeOffsets(local).Max();
return new DateTimeOffset(local, earliestOffset).ToUniversalTime();
}
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, timeZone));
}

Expand Down
3 changes: 3 additions & 0 deletions src/Odca.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"Documents": {
"StoragePath": "./private-documents"
},
"TimeZone": {
"DefaultId": "America/Sao_Paulo"
},
"Logging": {
"LogLevel": {
"Default": "Information",
Expand Down
57 changes: 57 additions & 0 deletions src/Odca.Application/Tenancy/TimeZonePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace Odca.Application.Tenancy;

public enum TimeZoneConfigurationError
{
Missing,
Empty,
Invalid,
Unavailable
}

public sealed class TimeZoneConfigurationException(
TimeZoneConfigurationError error,
string message,
Exception? innerException = null) : Exception(message, innerException)
{
public TimeZoneConfigurationError Error { get; } = error;
}

public static class TimeZonePolicy
{
public static TimeZoneInfo Resolve(string? organizationTimeZoneId, string? applicationDefaultTimeZoneId)
{
if (organizationTimeZoneId is not null)
{
if (string.IsNullOrWhiteSpace(organizationTimeZoneId))
throw new TimeZoneConfigurationException(TimeZoneConfigurationError.Empty, "O fuso horário da organização está vazio.");

return Find(organizationTimeZoneId.Trim());
}

if (applicationDefaultTimeZoneId is null)
throw new TimeZoneConfigurationException(TimeZoneConfigurationError.Missing, "Não há fuso horário da organização nem padrão da aplicação.");
if (string.IsNullOrWhiteSpace(applicationDefaultTimeZoneId))
throw new TimeZoneConfigurationException(TimeZoneConfigurationError.Empty, "O fuso horário padrão da aplicação está vazio.");

return Find(applicationDefaultTimeZoneId.Trim());
}

private static TimeZoneInfo Find(string id)
{
if (id.IndexOfAny(['\0', '\r', '\n']) >= 0)
throw new TimeZoneConfigurationException(TimeZoneConfigurationError.Invalid, "O identificador de fuso horário possui caracteres inválidos.");

try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (TimeZoneNotFoundException exception)
{
throw new TimeZoneConfigurationException(TimeZoneConfigurationError.Unavailable, $"O fuso horário '{id}' não está disponível neste ambiente.", exception);
}
catch (InvalidTimeZoneException exception)
{
throw new TimeZoneConfigurationException(TimeZoneConfigurationError.Invalid, $"Os dados do fuso horário '{id}' são inválidos.", exception);
}
}
}
17 changes: 15 additions & 2 deletions src/Odca.Worker/DocumentWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,26 @@ private async Task<ExtractedText> Extract(VersionItem item, CancellationToken ct
private string StoragePath(string key) => Path.Combine(Path.GetFullPath(configuration["Documents:StoragePath"] ?? "./private-documents"), key.Replace('/', Path.DirectorySeparatorChar));
private static async Task ScanResult(NpgsqlConnection c, Guid id, string status, string engine, CancellationToken ct)
=> _ = await c.ExecuteAsync(new CommandDefinition("UPDATE odca.document_versions SET security_status=@status,security_checked_at=now(),security_engine=@engine WHERE id=@id", new { id, status, engine }, cancellationToken: ct));
private static async Task<int> Run(string executable, IReadOnlyList<string> arguments, TimeSpan timeout, CancellationToken ct)
internal static async Task<int> Run(string executable, IReadOnlyList<string> arguments, TimeSpan timeout, CancellationToken ct)
{
var start = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
foreach (var argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start) ?? throw new InvalidOperationException("process-start-failed");
using var limit = CancellationTokenSource.CreateLinkedTokenSource(ct); limit.CancelAfter(timeout);
await process.WaitForExitAsync(limit.Token); return process.ExitCode;
try
{
await process.WaitForExitAsync(limit.Token);
return process.ExitCode;
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
}
throw;
}
}
private sealed record VersionItem(Guid Id, Guid TenantId, string StorageKey, string DetectedType);
private sealed record JobItem(Guid Id, Guid TenantId, Guid ContractId, Guid VersionId, Guid LeaseToken);
Expand Down
1 change: 1 addition & 0 deletions src/Odca.Worker/Odca.Worker.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Odca.IntegrationTests" />
<ProjectReference Include="..\Odca.Application\Odca.Application.csproj" />
<ProjectReference Include="..\Odca.Infrastructure\Odca.Infrastructure.csproj" />
<ProjectReference Include="..\Odca.Configuration\Odca.Configuration.csproj" />
Expand Down
1 change: 1 addition & 0 deletions src/Odca.Worker/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Odca.Configuration;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromMinutes(2));
var localConfiguration = LocalRuntimeConfiguration.Add(builder.Configuration, builder.Environment, args);
Console.WriteLine($"Configuração: ambiente={localConfiguration.Environment}; caminho={localConfiguration.Path ?? "não utilizado"}.");
builder.Services.AddHostedService<Worker>();
Expand Down
54 changes: 54 additions & 0 deletions tests/Odca.Domain.Tests/TimeZonePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Odca.Application.Tenancy;

namespace Odca.Domain.Tests;

public sealed class TimeZonePolicyTests
{
[Fact]
public void OrganizationTimeZoneTakesPrecedence()
{
var result = TimeZonePolicy.Resolve("UTC", "America/Sao_Paulo");

Assert.Equal(TimeZoneInfo.Utc.Id, result.Id);
}

[Fact]
public void ApplicationDefaultIsUsedOnlyWhenOrganizationValueIsAbsent()
{
var result = TimeZonePolicy.Resolve(null, "UTC");

Assert.Equal(TimeZoneInfo.Utc.Id, result.Id);
}

[Fact]
public void MissingOrganizationAndDefaultAreRejected()
{
var exception = Assert.Throws<TimeZoneConfigurationException>(() => TimeZonePolicy.Resolve(null, null));

Assert.Equal(TimeZoneConfigurationError.Missing, exception.Error);
}

[Fact]
public void EmptyOrganizationValueDoesNotSilentlyUseDefault()
{
var exception = Assert.Throws<TimeZoneConfigurationException>(() => TimeZonePolicy.Resolve(" ", "UTC"));

Assert.Equal(TimeZoneConfigurationError.Empty, exception.Error);
}

[Fact]
public void InvalidIdentifierIsRejectedBeforePlatformLookup()
{
var exception = Assert.Throws<TimeZoneConfigurationException>(() => TimeZonePolicy.Resolve("invalid\nzone", "UTC"));

Assert.Equal(TimeZoneConfigurationError.Invalid, exception.Error);
}

[Fact]
public void IdentifierMissingFromTheOperatingSystemIsReportedAsUnavailable()
{
var exception = Assert.Throws<TimeZoneConfigurationException>(() => TimeZonePolicy.Resolve("ODCA/DefinitelyUnavailable", "UTC"));

Assert.Equal(TimeZoneConfigurationError.Unavailable, exception.Error);
}
}
32 changes: 28 additions & 4 deletions tests/Odca.IntegrationTests/ContractImportDateRangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public sealed class ContractImportDateRangeTests
[Fact]
public void NoDatesProduceNoTimestampLimits()
{
var range = ContractImportsController.CreateUtcRange(null, null, "UTC");
var range = ContractImportsController.CreateUtcRange(null, null, TimeZoneInfo.Utc);

Assert.Null(range.FromInclusive);
Assert.Null(range.ToExclusive);
Expand All @@ -19,7 +19,7 @@ public void ASingleDayUsesInclusiveStartAndExclusiveFollowingStart()
var range = ContractImportsController.CreateUtcRange(
new DateOnly(2026, 9, 16),
new DateOnly(2026, 9, 16),
"UTC");
TimeZoneInfo.Utc);

Assert.Equal(new DateTimeOffset(2026, 9, 16, 0, 0, 0, TimeSpan.Zero), range.FromInclusive);
Assert.Equal(new DateTimeOffset(2026, 9, 17, 0, 0, 0, TimeSpan.Zero), range.ToExclusive);
Expand All @@ -31,7 +31,7 @@ public void OrganizationTimeZoneControlsBothLimitsAcrossDaylightSavingChange()
var range = ContractImportsController.CreateUtcRange(
new DateOnly(2026, 3, 8),
new DateOnly(2026, 3, 8),
"America/New_York");
TimeZoneInfo.FindSystemTimeZoneById("America/New_York"));

Assert.Equal(new DateTimeOffset(2026, 3, 8, 5, 0, 0, TimeSpan.Zero), range.FromInclusive);
Assert.Equal(new DateTimeOffset(2026, 3, 9, 4, 0, 0, TimeSpan.Zero), range.ToExclusive);
Expand All @@ -40,9 +40,33 @@ public void OrganizationTimeZoneControlsBothLimitsAcrossDaylightSavingChange()
[Fact]
public void MaximumFinalDateDoesNotOverflow()
{
var range = ContractImportsController.CreateUtcRange(null, DateOnly.MaxValue, "UTC");
var range = ContractImportsController.CreateUtcRange(null, DateOnly.MaxValue, TimeZoneInfo.Utc);

Assert.Null(range.FromInclusive);
Assert.Null(range.ToExclusive);
}

[Fact]
public void InvertedRangeIsRejected()
{
Assert.Throws<ArgumentException>(() => ContractImportsController.CreateUtcRange(
new DateOnly(2026, 9, 17), new DateOnly(2026, 9, 16), TimeZoneInfo.Utc));
}

[Fact]
public void MinimumInitialDateDoesNotOverflow()
{
var range = ContractImportsController.CreateUtcRange(DateOnly.MinValue, null, TimeZoneInfo.Utc);

Assert.Equal(new DateTimeOffset(DateTime.MinValue, TimeSpan.Zero), range.FromInclusive);
}

[Fact]
public void ADaySkippedByTheTimeZoneIsRejected()
{
var zone = TimeZoneInfo.FindSystemTimeZoneById("Pacific/Apia");

Assert.Throws<InvalidOperationException>(() => ContractImportsController.CreateUtcRange(
new DateOnly(2011, 12, 30), null, zone));
}
}
Loading
Loading