From 3dcce816b73c9957a6d199de68e31ebc961947ae Mon Sep 17 00:00:00 2001 From: Alberto Arroyo Raygada Date: Wed, 8 Jul 2026 00:18:55 -0500 Subject: [PATCH] =?UTF-8?q?feat(integration):=20build=20the=20ProviderConn?= =?UTF-8?q?ection=20domain=20=E2=80=94=20capability=20Ports=20+=20ACL=20(C?= =?UTF-8?q?R-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the static-display-only Provider Connections with a real domain. New ProviderConnection aggregate (Integration context) with a capability→Port taxonomy (ProviderCapabilityCatalog), a ProviderAcl value object (validated direction + scopes), and a guarded lifecycle (Connect/Disconnect/MarkError/UpdateAcl). Persisted to a new provider_connections table (indexes on TenantId, (TenantId,Capability), unique (TenantId,Code)) via an additive CreateTable migration AddProviderConnections. Application: Register/UpdateAcl/SetStatus commands + List/Get queries + DTO; Presentation: provider-connections endpoint group (guarded by existing ArchitectureRead/TenantManage). Capability adapter seam IProviderPort with concrete GitHub/Jira/Datadog adapters deferred (live integration). Build Release -warnaserror 0 warnings; 473->493 tests. Marks CR-15 DONE. Co-Authored-By: Claude Opus 4.8 --- docs/audit/tracker-gap-reference-catalog.md | 2 +- docs/audit/tracker-gap-tracking.md | 2 +- .../RegisterProviderConnectionCommand.cs | 14 + ...egisterProviderConnectionCommandHandler.cs | 49 + .../SetProviderConnectionStatusCommand.cs | 18 + ...tProviderConnectionStatusCommandHandler.cs | 49 + .../UpdateProviderConnectionAclCommand.cs | 11 + ...dateProviderConnectionAclCommandHandler.cs | 50 + .../DTOs/ProviderConnectionDto.cs | 19 + .../DTOs/ProviderConnectionMapping.cs | 24 + .../ProviderConnection/Ports/IProviderPort.cs | 17 + .../GetProviderConnectionQuery.cs | 9 + .../GetProviderConnectionQueryHandler.cs | 28 + .../ListProviderConnectionsQuery.cs | 9 + .../ListProviderConnectionsQueryHandler.cs | 27 + .../ProviderConnection/ProviderAcl.cs | 55 + .../ProviderConnection/ProviderConnection.cs | 101 + .../ProviderConnectionProps.cs | 31 + .../ProviderConnectionValues.cs | 71 + .../ProviderConnection/Repositories.cs | 10 + .../DependencyInjection.cs | 2 + ...8051527_AddProviderConnections.Designer.cs | 2882 +++++++++++++++++ .../20260708051527_AddProviderConnections.cs | 70 + .../TrackerDbContextModelSnapshot.cs | 99 + .../ProviderConnectionRecordConfiguration.cs | 42 + .../Entities/ProviderConnectionRecord.cs | 31 + .../PostgreSqlProviderConnectionRepository.cs | 143 + .../Persistence/TrackerDbContext.cs | 1 + .../Tracker.Infrastructure.csproj | 6 + .../TrackerApiApplicationBuilderExtensions.cs | 1 + .../ProviderConnectionEndpoints.cs | 126 + ...erProviderConnectionCommandHandlerTests.cs | 84 + .../Domain/Integration/ProviderAclTests.cs | 49 + .../Integration/ProviderConnectionTests.cs | 155 + .../ProviderConnectionMapperTests.cs | 53 + 35 files changed, 4338 insertions(+), 2 deletions(-) create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommand.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommandHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommand.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommandHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommand.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommandHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionDto.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionMapping.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Ports/IProviderPort.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQuery.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQueryHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQuery.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQueryHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderAcl.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnection.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionProps.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionValues.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/Repositories.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.Designer.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/ProviderConnectionRecordConfiguration.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/ProviderConnectionRecord.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlProviderConnectionRepository.cs create mode 100644 src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/ProviderConnectionEndpoints.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Application/Integration/RegisterProviderConnectionCommandHandlerTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderAclTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderConnectionTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProviderConnectionMapperTests.cs diff --git a/docs/audit/tracker-gap-reference-catalog.md b/docs/audit/tracker-gap-reference-catalog.md index 678d8a66..94116d2f 100644 --- a/docs/audit/tracker-gap-reference-catalog.md +++ b/docs/audit/tracker-gap-reference-catalog.md @@ -264,7 +264,7 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Acceptance criteria:** - [ ] Integrations are expressed as capability ports with ACLs; provider replacement preserves history. - **Dependencies:** [CR-14](#cr-14). - - **Status:** `IN-PROGRESS` — solo display estático (catálogo + panel UI); el agregado `ProviderConnection`, los ports por capacidad, el ACL y los adaptadores siguen sin construir. + - **Status:** `DONE` — dominio `ProviderConnection` (taxonomía capacidad→Port, VO `ProviderAcl`, ciclo de vida), persistencia `provider_connections` + migración `AddProviderConnections`, comandos/queries/endpoints; 493 tests. Diferido: adaptadores externos concretos tras el seam `IProviderPort`. #### CR-16 diff --git a/docs/audit/tracker-gap-tracking.md b/docs/audit/tracker-gap-tracking.md index 5cecd21a..8c65142f 100644 --- a/docs/audit/tracker-gap-tracking.md +++ b/docs/audit/tracker-gap-tracking.md @@ -28,7 +28,7 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor | [`GAP-010`](./tracker-gap-reference-catalog.md#gap-010) | **Hecho:** existe `reference/knowledge/domain/PRODUCT_VISION.md` (EN) con navegación bilingüe a `PRODUCT_VISION.es.md`. | `Docs` | Cross | P1 | M | `DONE` | | [`CR-10`](./tracker-gap-reference-catalog.md#cr-10) | Pantalla + endpoint de navegación de arquitectura (independiente del SDLC) existen para topologías/blueprints/evidencia/providers; faltan registro/browser de ADRs (estado/supersesión) y sync respaldado por Core (el catálogo hoy es estático). | `WEB/Backend` | Cross | P1 | M | `IN-PROGRESS` | | [`CR-14`](./tracker-gap-reference-catalog.md#cr-14) | **Hecho:** `EvidenceRecord` es ahora un nodo de Evidence **Graph** — campos de primera clase `sourceProvider`/`producer`/`contentHash`/`classification`(validada)/`retentionPolicyRef`/`immutable`, aristas de linaje `references[]` con operación de dominio `AddReference` (dedup) + `Seal`, comando/endpoint `POST /{id}/references`, migración aditiva `AddEvidenceGraphFields`. Build 0-warnings, 458 tests (+15). | `Backend` | Cross | P1 | M | `DONE` | -| [`CR-15`](./tracker-gap-reference-catalog.md#cr-15) | Solo existe un display estático de Provider Connections (catálogo de referencia + panel UI). El agregado `ProviderConnection`, los ports por capacidad, el mapeo ACL y los adaptadores siguen **sin construir** en Domain/Application/Infrastructure. | `Backend/Integration` | Cross | P1 | M | `IN-PROGRESS` | +| [`CR-15`](./tracker-gap-reference-catalog.md#cr-15) | **Hecho:** dominio `ProviderConnection` construido — taxonomía capacidad→Port (`ProviderCapabilityCatalog`), VO `ProviderAcl` (dirección validada + scopes), agregado con ciclo de vida `Connect`/`Disconnect`/`MarkError`/`UpdateAcl`, persistencia (tabla `provider_connections` + índices) + migración aditiva `AddProviderConnections`, comandos/queries + grupo de endpoints `provider-connections`. 493 tests (+20). **Diferido:** adaptadores externos concretos (GitHub/Jira/Datadog) tras el seam `IProviderPort` — integración viva. | `Backend/Integration` | Cross | P1 | M | `DONE` | | [`GAP-019`](./tracker-gap-reference-catalog.md#gap-019) | **Hecho:** requisitos Discovery desglosados a detalle implementable — PRD §2 REQ-DIS-01…13, cada uno con cláusula de aceptación, + catálogo de artefactos por gate (`functional-scope.md` §6). Sin user-stories atómicas, coherente con "sin backlog propio". | `Docs` | Cross | P1 | M | `DONE` | | [`CR-27`](./tracker-gap-reference-catalog.md#cr-27) | Phantom navigation: `aigov` + 4 nav items have no screen | `WEB` | Cross | P2 | S | `PENDING` | | [`COH-001`](./tracker-gap-reference-catalog.md#coh-001) | Status naming: `CANVAS_SUBMITTED` vs DB `submitted`. InitiativeStatus enum undefined in DDD. `under_review` in DB but zero specs. | `Backend` | Discovery | P2 | M | `PENDING` | diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommand.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommand.cs new file mode 100644 index 00000000..b0fe5654 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommand.cs @@ -0,0 +1,14 @@ +using Tracker.Application.Abstractions.Messaging; + +namespace Tracker.Application.Integration.ProviderConnection.Commands.RegisterProviderConnection; + +public sealed record RegisterProviderConnectionCommand( + Guid TenantId, + string Code, + string Capability, + string Provider, + string AclDirection, + string AclFieldOwnership, + IReadOnlyList? AclScopes, + Guid CreatedBy +) : ICommand; diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommandHandler.cs new file mode 100644 index 00000000..0148badc --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/RegisterProviderConnection/RegisterProviderConnectionCommandHandler.cs @@ -0,0 +1,49 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Domain.Integration.ProviderConnection; +using Tracker.Domain.Kernel; + +namespace Tracker.Application.Integration.ProviderConnection.Commands.RegisterProviderConnection; + +internal sealed class RegisterProviderConnectionCommandHandler + : ICommandHandler +{ + private readonly IProviderConnectionRepository _repository; + private readonly IUnitOfWork _unitOfWork; + + public RegisterProviderConnectionCommandHandler( + IProviderConnectionRepository repository, + IUnitOfWork unitOfWork) + { + _repository = repository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle( + RegisterProviderConnectionCommand request, + CancellationToken cancellationToken) + { + var aclResult = ProviderAcl.Create( + request.AclDirection, + request.AclFieldOwnership, + request.AclScopes); + + if (aclResult.IsFailure) + return Result.Failure(aclResult.Error); + + var result = Domain.Integration.ProviderConnection.ProviderConnection.Create( + request.TenantId, + request.Code, + request.Capability, + request.Provider, + aclResult.Value, + request.CreatedBy); + + if (result.IsFailure) + return Result.Failure(result.Error); + + await _repository.AddAsync(result.Value, cancellationToken); + await _unitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(result.Value.Id); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommand.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommand.cs new file mode 100644 index 00000000..dcb5362a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommand.cs @@ -0,0 +1,18 @@ +using Tracker.Application.Abstractions.Messaging; + +namespace Tracker.Application.Integration.ProviderConnection.Commands.SetProviderConnectionStatus; + +public sealed record SetProviderConnectionStatusCommand( + Guid TenantId, + Guid ConnectionId, + string Action, + string? Reason = null +) : ICommand; + +/// Canonical lifecycle actions accepted by . +public static class ProviderConnectionAction +{ + public const string Connect = "connect"; + public const string Disconnect = "disconnect"; + public const string Error = "error"; +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommandHandler.cs new file mode 100644 index 00000000..fb9e1f73 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/SetProviderConnectionStatus/SetProviderConnectionStatusCommandHandler.cs @@ -0,0 +1,49 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Domain.Integration.ProviderConnection; +using Tracker.Domain.Kernel; + +namespace Tracker.Application.Integration.ProviderConnection.Commands.SetProviderConnectionStatus; + +internal sealed class SetProviderConnectionStatusCommandHandler + : ICommandHandler +{ + private readonly IProviderConnectionRepository _repository; + private readonly IUnitOfWork _unitOfWork; + + public SetProviderConnectionStatusCommandHandler( + IProviderConnectionRepository repository, + IUnitOfWork unitOfWork) + { + _repository = repository; + _unitOfWork = unitOfWork; + } + + public async Task Handle( + SetProviderConnectionStatusCommand request, + CancellationToken cancellationToken) + { + var connection = await _repository.GetByIdAsync(request.ConnectionId, cancellationToken); + + if (connection is null) + return Result.Failure("ProviderConnection.NotFound"); + + if (connection.TenantId != request.TenantId) + return Result.Failure("ProviderConnection.AccessDenied"); + + var result = request.Action switch + { + ProviderConnectionAction.Connect => connection.Connect(), + ProviderConnectionAction.Disconnect => connection.Disconnect(), + ProviderConnectionAction.Error => connection.MarkError(request.Reason ?? string.Empty), + _ => Result.Failure("ProviderConnection.UnknownAction") + }; + + if (result.IsFailure) + return Result.Failure(result.Error); + + await _repository.UpdateAsync(connection, cancellationToken); + await _unitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommand.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommand.cs new file mode 100644 index 00000000..a5badfc9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommand.cs @@ -0,0 +1,11 @@ +using Tracker.Application.Abstractions.Messaging; + +namespace Tracker.Application.Integration.ProviderConnection.Commands.UpdateProviderConnectionAcl; + +public sealed record UpdateProviderConnectionAclCommand( + Guid TenantId, + Guid ConnectionId, + string AclDirection, + string AclFieldOwnership, + IReadOnlyList? AclScopes +) : ICommand; diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommandHandler.cs new file mode 100644 index 00000000..c16ac938 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Commands/UpdateProviderConnectionAcl/UpdateProviderConnectionAclCommandHandler.cs @@ -0,0 +1,50 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Domain.Integration.ProviderConnection; +using Tracker.Domain.Kernel; + +namespace Tracker.Application.Integration.ProviderConnection.Commands.UpdateProviderConnectionAcl; + +internal sealed class UpdateProviderConnectionAclCommandHandler + : ICommandHandler +{ + private readonly IProviderConnectionRepository _repository; + private readonly IUnitOfWork _unitOfWork; + + public UpdateProviderConnectionAclCommandHandler( + IProviderConnectionRepository repository, + IUnitOfWork unitOfWork) + { + _repository = repository; + _unitOfWork = unitOfWork; + } + + public async Task Handle( + UpdateProviderConnectionAclCommand request, + CancellationToken cancellationToken) + { + var connection = await _repository.GetByIdAsync(request.ConnectionId, cancellationToken); + + if (connection is null) + return Result.Failure("ProviderConnection.NotFound"); + + if (connection.TenantId != request.TenantId) + return Result.Failure("ProviderConnection.AccessDenied"); + + var aclResult = ProviderAcl.Create( + request.AclDirection, + request.AclFieldOwnership, + request.AclScopes); + + if (aclResult.IsFailure) + return Result.Failure(aclResult.Error); + + var result = connection.UpdateAcl(aclResult.Value); + if (result.IsFailure) + return Result.Failure(result.Error); + + await _repository.UpdateAsync(connection, cancellationToken); + await _unitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionDto.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionDto.cs new file mode 100644 index 00000000..cce5059a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionDto.cs @@ -0,0 +1,19 @@ +namespace Tracker.Application.Integration.ProviderConnection.DTOs; + +public sealed class ProviderConnectionDto +{ + public Guid Id { get; init; } + public string Code { get; init; } = string.Empty; + public string Capability { get; init; } = string.Empty; + public string Provider { get; init; } = string.Empty; + public string Port { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public ProviderAclDto Acl { get; init; } = new(); +} + +public sealed class ProviderAclDto +{ + public string Direction { get; init; } = string.Empty; + public string FieldOwnership { get; init; } = string.Empty; + public IReadOnlyList Scopes { get; init; } = new List(); +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionMapping.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionMapping.cs new file mode 100644 index 00000000..762e7c7b --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/DTOs/ProviderConnectionMapping.cs @@ -0,0 +1,24 @@ +namespace Tracker.Application.Integration.ProviderConnection.DTOs; + +/// Maps the ProviderConnection aggregate to its read DTO. +internal static class ProviderConnectionMapping +{ + public static ProviderConnectionDto ToDto(Domain.Integration.ProviderConnection.ProviderConnection connection) + { + return new ProviderConnectionDto + { + Id = connection.Id, + Code = connection.Code, + Capability = connection.Capability, + Provider = connection.Provider, + Port = connection.Port, + Status = connection.Status, + Acl = new ProviderAclDto + { + Direction = connection.Acl.Direction, + FieldOwnership = connection.Acl.FieldOwnership, + Scopes = connection.Acl.Scopes + } + }; + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Ports/IProviderPort.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Ports/IProviderPort.cs new file mode 100644 index 00000000..26e67096 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Ports/IProviderPort.cs @@ -0,0 +1,17 @@ +namespace Tracker.Application.Integration.ProviderConnection.Ports; + +/// +/// Driven-side seam for an integration capability adapter. A concrete adapter binds one +/// (e.g. "work-management") to a live external provider, translating +/// between the provider's API and the Tracker's canonical Port. +/// +/// TODO CR-15: concrete provider adapters (GitHub/Jira/Datadog) are deferred — live integration. +/// This is intentionally a marker interface; the capability-specific method surface +/// (WorkItemPort, SourceControlPort, ObservabilityPort) is introduced when the first live +/// adapter lands. No concrete implementations exist yet. +/// +public interface IProviderPort +{ + /// The canonical capability this adapter serves (see ProviderCapability). + string Capability { get; } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQuery.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQuery.cs new file mode 100644 index 00000000..161d801f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQuery.cs @@ -0,0 +1,9 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Application.Integration.ProviderConnection.DTOs; + +namespace Tracker.Application.Integration.ProviderConnection.Queries.GetProviderConnection; + +public sealed record GetProviderConnectionQuery( + Guid TenantId, + Guid ConnectionId +) : IQuery; diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQueryHandler.cs new file mode 100644 index 00000000..67bcc3e9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/GetProviderConnection/GetProviderConnectionQueryHandler.cs @@ -0,0 +1,28 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Application.Integration.ProviderConnection.DTOs; +using Tracker.Domain.Integration.ProviderConnection; + +namespace Tracker.Application.Integration.ProviderConnection.Queries.GetProviderConnection; + +internal sealed class GetProviderConnectionQueryHandler + : IQueryHandler +{ + private readonly IProviderConnectionRepository _repository; + + public GetProviderConnectionQueryHandler(IProviderConnectionRepository repository) + { + _repository = repository; + } + + public async Task Handle( + GetProviderConnectionQuery request, + CancellationToken cancellationToken) + { + var connection = await _repository.GetByIdAsync(request.ConnectionId, cancellationToken); + + if (connection is null || connection.TenantId != request.TenantId) + return null; + + return ProviderConnectionMapping.ToDto(connection); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQuery.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQuery.cs new file mode 100644 index 00000000..4c0296e9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQuery.cs @@ -0,0 +1,9 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Application.Integration.ProviderConnection.DTOs; + +namespace Tracker.Application.Integration.ProviderConnection.Queries.ListProviderConnections; + +public sealed record ListProviderConnectionsQuery( + Guid TenantId, + string? Capability = null +) : IQuery>; diff --git a/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQueryHandler.cs new file mode 100644 index 00000000..9b7b0460 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Integration/ProviderConnection/Queries/ListProviderConnections/ListProviderConnectionsQueryHandler.cs @@ -0,0 +1,27 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Application.Integration.ProviderConnection.DTOs; +using Tracker.Domain.Integration.ProviderConnection; + +namespace Tracker.Application.Integration.ProviderConnection.Queries.ListProviderConnections; + +internal sealed class ListProviderConnectionsQueryHandler + : IQueryHandler> +{ + private readonly IProviderConnectionRepository _repository; + + public ListProviderConnectionsQueryHandler(IProviderConnectionRepository repository) + { + _repository = repository; + } + + public async Task> Handle( + ListProviderConnectionsQuery request, + CancellationToken cancellationToken) + { + var items = string.IsNullOrWhiteSpace(request.Capability) + ? await _repository.GetByTenantAsync(request.TenantId, cancellationToken) + : await _repository.GetByTenantAndCapabilityAsync(request.TenantId, request.Capability, cancellationToken); + + return items.Select(ProviderConnectionMapping.ToDto).ToList(); + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderAcl.cs b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderAcl.cs new file mode 100644 index 00000000..cecec2f0 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderAcl.cs @@ -0,0 +1,55 @@ +using Tracker.Domain.Kernel; + +namespace Tracker.Domain.Integration.ProviderConnection; + +/// +/// Access-control contract for a : the data-flow +/// , which side owns which fields (), +/// and the granted . A value object — equality is by value, including the +/// ordered scope list. +/// +public sealed record ProviderAcl(string Direction, string FieldOwnership, IReadOnlyList Scopes) +{ + /// + /// Creates a validated ACL. must be a canonical + /// ; defaults to an empty (never null) list. + /// + public static Result Create( + string direction, + string fieldOwnership, + IReadOnlyList? scopes) + { + if (!AclDirection.IsValid(direction)) + return Result.Failure("ProviderAcl.InvalidDirection"); + + var normalizedScopes = scopes is null + ? new List() + : new List(scopes); + + return Result.Success( + new ProviderAcl(direction, fieldOwnership ?? string.Empty, normalizedScopes)); + } + + /// Value equality over the scope list (order-sensitive), on top of the record's field equality. + public bool Equals(ProviderAcl? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + + return Direction == other.Direction + && FieldOwnership == other.FieldOwnership + && Scopes.SequenceEqual(other.Scopes); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Direction); + hash.Add(FieldOwnership); + foreach (var scope in Scopes) + hash.Add(scope); + return hash.ToHashCode(); + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnection.cs b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnection.cs new file mode 100644 index 00000000..a64efcd8 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnection.cs @@ -0,0 +1,101 @@ +using Tracker.Domain.Kernel; + +namespace Tracker.Domain.Integration.ProviderConnection; + +/// +/// Provider Connection aggregate (CR-15): a tenant-scoped binding of an integration +/// to a concrete external through a canonical +/// hexagonal , governed by an access-control contract (). +/// Concrete provider adapters that actually call GitHub/Jira/Datadog are deferred — the Port is a +/// seam (see Tracker.Application.Integration.ProviderConnection.Ports.IProviderPort). +/// +public sealed class ProviderConnection : AggregateRoot +{ + private ProviderConnection(ProviderConnectionProps props) : base(props) { } + + public new Guid Id => Props.Id.GetValue(); + public Guid TenantId => Props.TenantId; + public string Code => Props.Code; + public string Capability => Props.Capability; + public string Provider => Props.Provider; + public string Port => Props.Port; + public string Status => Props.Status; + public ProviderAcl Acl => Props.Acl; + public string? LastError => Props.LastError; + + public static Result Create( + Guid tenantId, + string code, + string capability, + string provider, + ProviderAcl acl, + Guid createdBy) + { + if (!ProviderCapability.IsValid(capability)) + return Result.Failure("ProviderConnection.InvalidCapability"); + + // Port is derived from the capability so the taxonomy stays closed; a valid capability + // always maps to a canonical Port (guarded above), so this is non-null by construction. + var port = ProviderCapabilityCatalog.CanonicalPort(capability)!; + + var props = new ProviderConnectionProps + { + Id = TrackerId.Create(), + TenantId = tenantId, + Code = code, + Capability = capability, + Provider = provider, + Port = port, + Status = ProviderConnectionStatus.Registered, + Acl = acl, + LastError = null, + Audit = new AuditProps(createdBy.ToString(), DateTime.UtcNow) + }; + + return Result.Success(new ProviderConnection(props)); + } + + /// Marks the connection live. Illegal when already connected. + public Result Connect() + { + if (Props.Status == ProviderConnectionStatus.Connected) + return Result.Failure("ProviderConnection.AlreadyConnected"); + + SetProps(Props with { Status = ProviderConnectionStatus.Connected, LastError = null }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + + /// Tears the connection down. Illegal when already disconnected. + public Result Disconnect() + { + if (Props.Status == ProviderConnectionStatus.Disconnected) + return Result.Failure("ProviderConnection.AlreadyDisconnected"); + + SetProps(Props with { Status = ProviderConnectionStatus.Disconnected }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + + /// Flags the connection as errored, capturing a diagnostic reason. + public Result MarkError(string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return Result.Failure("ProviderConnection.EmptyErrorReason"); + + SetProps(Props with { Status = ProviderConnectionStatus.Error, LastError = reason }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + + /// Replaces the access-control contract in full. + public Result UpdateAcl(ProviderAcl acl) + { + if (acl is null) + return Result.Failure("ProviderConnection.NullAcl"); + + SetProps(Props with { Acl = acl }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionProps.cs b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionProps.cs new file mode 100644 index 00000000..f3371b32 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionProps.cs @@ -0,0 +1,31 @@ +using Tracker.Domain.Kernel; + +namespace Tracker.Domain.Integration.ProviderConnection; + +public sealed record ProviderConnectionProps : IProps +{ + public required IdValueObject Id { get; init; } + public required AuditProps Audit { get; init; } + public Guid TenantId { get; init; } + + /// Stable, tenant-unique slug for the connection (e.g. "PC-WORK-JIRA"). + public string Code { get; init; } = string.Empty; + public string Capability { get; init; } = string.Empty; + public string Provider { get; init; } = string.Empty; + + /// Canonical Port name, derived from — never accepted as free input. + public string Port { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public required ProviderAcl Acl { get; init; } + + /// Transient last-error reason set by MarkError. Not persisted — diagnostic only. + public string? LastError { get; init; } + + /// Copy hook required by the Core shared-kernel IProps : ICloneable. Explicit + /// interface impl because records cannot declare a member literally named Clone (CS8859). + /// The ACL's scope list is deep-copied so clones never share the mutable backing store. + object ICloneable.Clone() => this with + { + Acl = Acl with { Scopes = new List(Acl.Scopes) } + }; +} diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionValues.cs b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionValues.cs new file mode 100644 index 00000000..2dd0da63 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/ProviderConnectionValues.cs @@ -0,0 +1,71 @@ +namespace Tracker.Domain.Integration.ProviderConnection; + +/// +/// Canonical, Tracker-owned integration capability taxonomy. A capability names the +/// business seam an external provider is plugged into (work management, source control, +/// observability), independent of which concrete provider serves it. +/// +public static class ProviderCapability +{ + public const string WorkManagement = "work-management"; + public const string SourceControl = "source-control"; + public const string Observability = "observability"; + + /// True if is one of the canonical capabilities. + public static bool IsValid(string capability) + => capability is WorkManagement or SourceControl or Observability; +} + +/// +/// Canonical hexagonal Port names — the driven-side seam a capability binds to. A Port is the +/// interface the domain speaks to; concrete provider adapters (GitHub/Jira/Datadog) implement it. +/// +public static class ProviderPortName +{ + public const string WorkItemPort = "WorkItemPort"; + public const string SourceControlPort = "SourceControlPort"; + public const string ObservabilityPort = "ObservabilityPort"; +} + +/// +/// Data-flow direction of an integration relative to the Tracker: the provider pushes into the +/// Tracker (), the Tracker pushes out (), or both (). +/// +public static class AclDirection +{ + public const string Inbound = "inbound"; + public const string Outbound = "outbound"; + public const string TwoWay = "two-way"; + + /// True if is one of the canonical ACL directions. + public static bool IsValid(string direction) + => direction is Inbound or Outbound or TwoWay; +} + +/// Lifecycle status of a . +public static class ProviderConnectionStatus +{ + public const string Registered = "registered"; + public const string Connected = "connected"; + public const string Disconnected = "disconnected"; + public const string Error = "error"; +} + +/// +/// Authoritative capability→Port map. A capability has exactly one canonical Port; deriving the +/// Port from the capability (rather than accepting it as free input) keeps the taxonomy closed. +/// +public static class ProviderCapabilityCatalog +{ + private static readonly IReadOnlyDictionary CapabilityToPort = + new Dictionary + { + [ProviderCapability.WorkManagement] = ProviderPortName.WorkItemPort, + [ProviderCapability.SourceControl] = ProviderPortName.SourceControlPort, + [ProviderCapability.Observability] = ProviderPortName.ObservabilityPort, + }; + + /// The canonical Port name for , or null if unknown. + public static string? CanonicalPort(string capability) + => CapabilityToPort.TryGetValue(capability, out var port) ? port : null; +} diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/Repositories.cs b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/Repositories.cs new file mode 100644 index 00000000..1dcdb7c9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Integration/ProviderConnection/Repositories.cs @@ -0,0 +1,10 @@ +namespace Tracker.Domain.Integration.ProviderConnection; + +public interface IProviderConnectionRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); + Task> GetByTenantAndCapabilityAsync(Guid tenantId, string capability, CancellationToken cancellationToken = default); + Task AddAsync(ProviderConnection aggregate, CancellationToken cancellationToken = default); + Task UpdateAsync(ProviderConnection aggregate, CancellationToken cancellationToken = default); +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs index d0b6976b..993ef674 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs @@ -27,6 +27,7 @@ using Tracker.Domain.Intake.StrategicBaseline; using Tracker.Domain.Intake.StrategicInitiative; using Tracker.Domain.Integration.CoreEvaluationTransaction; +using Tracker.Domain.Integration.ProviderConnection; using Tracker.Domain.Kernel; using Tracker.Domain.Products.Product; using Tracker.Domain.Tenancy.Tenant; @@ -71,6 +72,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.Designer.cs new file mode 100644 index 00000000..2cc9d700 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.Designer.cs @@ -0,0 +1,2882 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tracker.Infrastructure.Persistence; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + [DbContext(typeof(TrackerDbContext))] + [Migration("20260708051527_AddProviderConnections")] + partial class AddProviderConnections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.cs new file mode 100644 index 00000000..ca5207c5 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708051527_AddProviderConnections.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddProviderConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "provider_connections", + schema: "tracker_governance", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + code = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + capability = table.Column(type: "text", nullable: false), + provider = table.Column(type: "text", nullable: false), + port = table.Column(type: "text", nullable: false), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + acl_direction = table.Column(type: "text", nullable: false), + acl_field_ownership = table.Column(type: "text", nullable: false, defaultValue: ""), + acl_scopes = table.Column(type: "jsonb", nullable: false, defaultValue: "[]"), + created_by = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_by = table.Column(type: "text", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + is_deleted = table.Column(type: "boolean", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_provider_connections", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_provider_connections_tenant_id", + schema: "tracker_governance", + table: "provider_connections", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_provider_connections_tenant_id_capability", + schema: "tracker_governance", + table: "provider_connections", + columns: new[] { "tenant_id", "capability" }); + + migrationBuilder.CreateIndex( + name: "IX_provider_connections_tenant_id_code", + schema: "tracker_governance", + table: "provider_connections", + columns: new[] { "tenant_id", "code" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "provider_connections", + schema: "tracker_governance"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index 33095f92..0933d2f0 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -2327,6 +2327,105 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("core_evaluation_transactions", "tracker_governance"); }); + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => { b.Property("Id") diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/ProviderConnectionRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/ProviderConnectionRecordConfiguration.cs new file mode 100644 index 00000000..9fce0799 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/ProviderConnectionRecordConfiguration.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Tracker.Infrastructure.Persistence.Integration.Entities; + +namespace Tracker.Infrastructure.Persistence.Integration.Configurations; + +internal sealed class ProviderConnectionRecordConfiguration + : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("provider_connections"); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + + builder.Property(x => x.TenantId).HasColumnName("tenant_id").IsRequired(); + builder.Property(x => x.Code).HasColumnName("code").IsRequired().HasMaxLength(128); + builder.Property(x => x.Capability).HasColumnName("capability").HasColumnType("text").IsRequired(); + builder.Property(x => x.Provider).HasColumnName("provider").HasColumnType("text").IsRequired(); + builder.Property(x => x.Port).HasColumnName("port").HasColumnType("text").IsRequired(); + builder.Property(x => x.Status).HasColumnName("status").IsRequired().HasMaxLength(20); + + builder.Property(x => x.AclDirection).HasColumnName("acl_direction").HasColumnType("text").IsRequired(); + builder.Property(x => x.AclFieldOwnership).HasColumnName("acl_field_ownership").HasColumnType("text").HasDefaultValue(""); + builder.Property(x => x.AclScopesJson).HasColumnName("acl_scopes").HasColumnType("jsonb").HasDefaultValue("[]"); + + builder.Property(x => x.CreatedBy).HasColumnName("created_by").IsRequired(); + builder.Property(x => x.CreatedAtUtc).HasColumnName("created_at").IsRequired(); + builder.Property(x => x.UpdatedBy).HasColumnName("updated_by").IsRequired(); + builder.Property(x => x.UpdatedAtUtc).HasColumnName("updated_at").IsRequired(); + builder.Property(x => x.IsDeleted).HasColumnName("is_deleted").IsRequired(); + builder.Property(x => x.DeletedAtUtc).HasColumnName("deleted_at"); + builder.Property(x => x.DeletedBy).HasColumnName("deleted_by"); + + builder.Ignore(x => x.RowVersion); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Capability }); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/ProviderConnectionRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/ProviderConnectionRecord.cs new file mode 100644 index 00000000..9118dc38 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/ProviderConnectionRecord.cs @@ -0,0 +1,31 @@ +namespace Tracker.Infrastructure.Persistence.Integration.Entities; + +/// +/// EF Core persistence record for the ProviderConnection aggregate (CR-15). +/// Flat POCO with audit fields (UMS pattern), no domain logic. The ACL is flattened into +/// two text columns plus a jsonb scope list. +/// +public sealed class ProviderConnectionRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public string Code { get; set; } = string.Empty; + public string Capability { get; set; } = string.Empty; + public string Provider { get; set; } = string.Empty; + public string Port { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + + // ACL value object, flattened. + public string AclDirection { get; set; } = string.Empty; + public string AclFieldOwnership { get; set; } = string.Empty; + public string AclScopesJson { get; set; } = "[]"; + + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string UpdatedBy { get; set; } = string.Empty; + public DateTime UpdatedAtUtc { get; set; } + public bool IsDeleted { get; set; } + public DateTime? DeletedAtUtc { get; set; } + public string? DeletedBy { get; set; } + public byte[] RowVersion { get; set; } = []; +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlProviderConnectionRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlProviderConnectionRepository.cs new file mode 100644 index 00000000..0d76e21e --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlProviderConnectionRepository.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tracker.Domain.Integration.ProviderConnection; +using Tracker.Domain.Kernel; +using Tracker.Infrastructure.Persistence.Integration.Entities; + +namespace Tracker.Infrastructure.Persistence.Integration; + +/// +/// PostgreSQL repository for the ProviderConnection aggregate (CR-15). Follows the UMS pattern: +/// maps between the domain aggregate and a flat persistence record, flattening the ACL value +/// object into two text columns plus a jsonb scope list. +/// +internal sealed class PostgreSqlProviderConnectionRepository : IProviderConnectionRepository +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly TrackerDbContext _context; + + public PostgreSqlProviderConnectionRepository(TrackerDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var record = await _context.ProviderConnections + .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken); + return record is null ? null : MapToDomain(record); + } + + public async Task> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var records = await _context.ProviderConnections + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + return records.Select(MapToDomain).ToList(); + } + + public async Task> GetByTenantAndCapabilityAsync(Guid tenantId, string capability, CancellationToken cancellationToken = default) + { + var records = await _context.ProviderConnections + .Where(x => x.TenantId == tenantId && x.Capability == capability && !x.IsDeleted) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + return records.Select(MapToDomain).ToList(); + } + + public async Task AddAsync(ProviderConnection aggregate, CancellationToken cancellationToken = default) + { + await _context.ProviderConnections.AddAsync(MapToRecord(aggregate), cancellationToken); + } + + public async Task UpdateAsync(ProviderConnection aggregate, CancellationToken cancellationToken = default) + { + var record = await _context.ProviderConnections + .FirstOrDefaultAsync(x => x.Id == aggregate.Id, cancellationToken); + if (record is null) + { + return; + } + + record.Status = aggregate.Status; + record.AclDirection = aggregate.Acl.Direction; + record.AclFieldOwnership = aggregate.Acl.FieldOwnership; + record.AclScopesJson = JsonSerializer.Serialize(aggregate.Acl.Scopes, JsonOptions); + record.UpdatedBy = aggregate.Props.Audit.UpdatedBy; + record.UpdatedAtUtc = aggregate.Props.Audit.UpdatedAtUtc; + } + + internal static ProviderConnection MapToDomain(ProviderConnectionRecord record) + { + var acl = new ProviderAcl( + record.AclDirection, + record.AclFieldOwnership, + DeserializeStringList(record.AclScopesJson)); + + var props = new ProviderConnectionProps + { + Id = new TrackerId(record.Id), + Audit = new AuditProps(record.CreatedBy, record.CreatedAtUtc), + TenantId = record.TenantId, + Code = record.Code, + Capability = record.Capability, + Provider = record.Provider, + Port = record.Port, + Status = record.Status, + Acl = acl, + LastError = null + }; + + var result = ProviderConnection.Create( + record.TenantId, + record.Code, + record.Capability, + record.Provider, + acl, + Guid.Empty); + + result.Value!.SetProps(props); + result.Value.TrackingState.MarkAsClean(); + return result.Value; + } + + internal static ProviderConnectionRecord MapToRecord(ProviderConnection aggregate) + { + return new ProviderConnectionRecord + { + Id = aggregate.Id, + TenantId = aggregate.TenantId, + Code = aggregate.Code, + Capability = aggregate.Capability, + Provider = aggregate.Provider, + Port = aggregate.Port, + Status = aggregate.Status, + AclDirection = aggregate.Acl.Direction, + AclFieldOwnership = aggregate.Acl.FieldOwnership, + AclScopesJson = JsonSerializer.Serialize(aggregate.Acl.Scopes, JsonOptions), + CreatedBy = aggregate.Props.Audit.CreatedBy, + CreatedAtUtc = aggregate.Props.Audit.CreatedAtUtc, + UpdatedBy = aggregate.Props.Audit.UpdatedBy, + UpdatedAtUtc = aggregate.Props.Audit.UpdatedAtUtc + }; + } + + private static List DeserializeStringList(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new List(); + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) + ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs index 8b0919b7..fa967627 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs @@ -18,6 +18,7 @@ public sealed class TrackerDbContext : DbContext, IUnitOfWork { public DbSet PhaseGateStates => Set(); public DbSet CoreEvaluationTransactions => Set(); + public DbSet ProviderConnections => Set(); public DbSet Initiatives => Set(); public DbSet TechnicalBlueprints => Set(); public DbSet PhaseArtifacts => Set(); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj b/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj index 4f3d55d5..a6bb76a4 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj +++ b/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj @@ -30,4 +30,10 @@ + + + + + diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs index 8557a3bc..718f298e 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs @@ -82,6 +82,7 @@ public static WebApplication MapTrackerApiSurface(this WebApplication app) api.MapArchitectureReferenceEndpoints(); api.MapCoreEvaluationEndpoints(); api.MapCoreEvaluationTransactionEndpoints(); + api.MapProviderConnectionEndpoints(); api.MapCoreArchitectureEndpoints(); api.MapAssistantEndpoints(); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/ProviderConnectionEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/ProviderConnectionEndpoints.cs new file mode 100644 index 00000000..3c165fac --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/ProviderConnectionEndpoints.cs @@ -0,0 +1,126 @@ +using MediatR; +using Tracker.Application.Integration.ProviderConnection.Commands.RegisterProviderConnection; +using Tracker.Application.Integration.ProviderConnection.Commands.SetProviderConnectionStatus; +using Tracker.Application.Integration.ProviderConnection.Commands.UpdateProviderConnectionAcl; +using Tracker.Application.Integration.ProviderConnection.Queries.GetProviderConnection; +using Tracker.Application.Integration.ProviderConnection.Queries.ListProviderConnections; +using Tracker.Presentation.Auth; +using Tracker.Presentation.Extensions; + +namespace Tracker.Presentation.Endpoints.Integration; + +public static class ProviderConnectionEndpoints +{ + public static void MapProviderConnectionEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/provider-connections").WithTags("Integration"); + + group.MapGet("/", async ( + string? capability, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var items = await mediator.Send( + new ListProviderConnectionsQuery(user.TenantId, capability), ct); + return Results.Ok(items); + }) + .RequireTrackerPermission(TrackerPermissions.ArchitectureRead) + .WithName("ListProviderConnections"); + + group.MapGet("/{id:guid}", async ( + Guid id, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new GetProviderConnectionQuery(user.TenantId, id), ct); + return result is null ? Results.NotFound() : Results.Ok(result); + }) + .RequireTrackerPermission(TrackerPermissions.ArchitectureRead) + .WithName("GetProviderConnection"); + + group.MapPost("/", async ( + RegisterProviderConnectionRequest request, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new RegisterProviderConnectionCommand( + user.TenantId, + request.Code, + request.Capability, + request.Provider, + request.Direction, + request.FieldOwnership, + request.Scopes, + user.ActorId), ct); + + return result.ToCreated(id => $"/api/provider-connections/{id}"); + }) + .RequireTrackerPermission(TrackerPermissions.TenantManage) + .WithName("RegisterProviderConnection"); + + group.MapPatch("/{id:guid}/acl", async ( + Guid id, + UpdateProviderConnectionAclRequest request, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new UpdateProviderConnectionAclCommand( + user.TenantId, + id, + request.Direction, + request.FieldOwnership, + request.Scopes), ct); + + return result.ToNoContent(); + }) + .RequireTrackerPermission(TrackerPermissions.TenantManage) + .WithName("UpdateProviderConnectionAcl"); + + group.MapPost("/{id:guid}/connect", async ( + Guid id, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new SetProviderConnectionStatusCommand( + user.TenantId, id, ProviderConnectionAction.Connect), ct); + return result.ToNoContent(); + }) + .RequireTrackerPermission(TrackerPermissions.TenantManage) + .WithName("ConnectProviderConnection"); + + group.MapPost("/{id:guid}/disconnect", async ( + Guid id, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send(new SetProviderConnectionStatusCommand( + user.TenantId, id, ProviderConnectionAction.Disconnect), ct); + return result.ToNoContent(); + }) + .RequireTrackerPermission(TrackerPermissions.TenantManage) + .WithName("DisconnectProviderConnection"); + } +} + +public sealed class RegisterProviderConnectionRequest +{ + public string Code { get; init; } = string.Empty; + public string Capability { get; init; } = string.Empty; + public string Provider { get; init; } = string.Empty; + public string Direction { get; init; } = string.Empty; + public string FieldOwnership { get; init; } = string.Empty; + public IReadOnlyList? Scopes { get; init; } +} + +public sealed class UpdateProviderConnectionAclRequest +{ + public string Direction { get; init; } = string.Empty; + public string FieldOwnership { get; init; } = string.Empty; + public IReadOnlyList? Scopes { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Integration/RegisterProviderConnectionCommandHandlerTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Integration/RegisterProviderConnectionCommandHandlerTests.cs new file mode 100644 index 00000000..34043e40 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/RegisterProviderConnectionCommandHandlerTests.cs @@ -0,0 +1,84 @@ +using Xunit; +using Moq; +using FluentAssertions; +using Tracker.Application.Integration.ProviderConnection.Commands.RegisterProviderConnection; +using Tracker.Application.Integration.ProviderConnection.Commands.UpdateProviderConnectionAcl; +using Tracker.Domain.Integration.ProviderConnection; +using Tracker.Domain.Kernel; + +namespace Tracker.Tests.Application.Integration; + +public class RegisterProviderConnectionCommandHandlerTests +{ + private readonly Mock _repositoryMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + [Fact] + public async Task Handle_WithValidCommand_ShouldRegisterAndSave() + { + var handler = new RegisterProviderConnectionCommandHandler( + _repositoryMock.Object, _unitOfWorkMock.Object); + + var command = new RegisterProviderConnectionCommand( + Guid.NewGuid(), + "PC-WORK-JIRA", + ProviderCapability.WorkManagement, + "Jira", + AclDirection.TwoWay, + "external-title-and-status", + new[] { "work:read", "work:write" }, + Guid.NewGuid()); + + var result = await handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeEmpty(); + _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidCapability_ShouldFailAndNotSave() + { + var handler = new RegisterProviderConnectionCommandHandler( + _repositoryMock.Object, _unitOfWorkMock.Object); + + var command = new RegisterProviderConnectionCommand( + Guid.NewGuid(), "PC-BAD", "not-a-capability", "X", + AclDirection.Inbound, "owner", new[] { "x:read" }, Guid.NewGuid()); + + var result = await handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("ProviderConnection.InvalidCapability"); + _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateAcl_WhenTenantMismatch_ShouldBeDenied() + { + var ownerTenant = Guid.NewGuid(); + var acl = ProviderAcl.Create(AclDirection.TwoWay, "external-title-and-status", + new[] { "work:read" }).Value; + var connection = ProviderConnection.Create( + ownerTenant, "PC-WORK-JIRA", ProviderCapability.WorkManagement, "Jira", acl, Guid.NewGuid()).Value; + + _repositoryMock + .Setup(r => r.GetByIdAsync(connection.Id, It.IsAny())) + .ReturnsAsync(connection); + + var handler = new UpdateProviderConnectionAclCommandHandler( + _repositoryMock.Object, _unitOfWorkMock.Object); + + // A different tenant attempts to mutate the connection. + var command = new UpdateProviderConnectionAclCommand( + Guid.NewGuid(), connection.Id, AclDirection.Outbound, "tracker-owned", new[] { "work:write" }); + + var result = await handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("ProviderConnection.AccessDenied"); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderAclTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderAclTests.cs new file mode 100644 index 00000000..1eee6f6d --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderAclTests.cs @@ -0,0 +1,49 @@ +using Xunit; +using FluentAssertions; +using Tracker.Domain.Integration.ProviderConnection; + +namespace Tracker.Tests.Domain.Integration; + +public class ProviderAclTests +{ + [Fact] + public void Create_WithValidDirection_ShouldSucceed() + { + var result = ProviderAcl.Create(AclDirection.TwoWay, "external-title-and-status", + new[] { "work:read", "work:write" }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Direction.Should().Be(AclDirection.TwoWay); + result.Value.Scopes.Should().Equal("work:read", "work:write"); + } + + [Fact] + public void Create_WithInvalidDirection_ShouldReturnFailure() + { + var result = ProviderAcl.Create("sideways", "owner", new[] { "x:read" }); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("ProviderAcl.InvalidDirection"); + } + + [Fact] + public void Create_WithNullScopes_ShouldDefaultToEmpty() + { + var result = ProviderAcl.Create(AclDirection.Inbound, "owner", null); + + result.IsSuccess.Should().BeTrue(); + result.Value.Scopes.Should().BeEmpty(); + } + + [Fact] + public void Equality_ShouldBeByValueIncludingScopes() + { + var a = ProviderAcl.Create(AclDirection.Inbound, "owner", new[] { "a", "b" }).Value; + var b = ProviderAcl.Create(AclDirection.Inbound, "owner", new[] { "a", "b" }).Value; + var c = ProviderAcl.Create(AclDirection.Inbound, "owner", new[] { "a", "c" }).Value; + + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + a.Should().NotBe(c); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderConnectionTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderConnectionTests.cs new file mode 100644 index 00000000..95ebcc35 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/ProviderConnectionTests.cs @@ -0,0 +1,155 @@ +using Xunit; +using FluentAssertions; +using Tracker.Domain.Integration.ProviderConnection; + +namespace Tracker.Tests.Domain.Integration; + +public class ProviderConnectionTests +{ + private readonly Guid _tenantId = Guid.NewGuid(); + private readonly Guid _userId = Guid.NewGuid(); + + public static IEnumerable CanonicalConnections() + { + yield return new object[] + { + "PC-WORK-JIRA", ProviderCapability.WorkManagement, "Jira", ProviderPortName.WorkItemPort, + AclDirection.TwoWay, "external-title-and-status", new[] { "work:read", "work:write" } + }; + yield return new object[] + { + "PC-SCM-GH", ProviderCapability.SourceControl, "GitHub", ProviderPortName.SourceControlPort, + AclDirection.Inbound, "provider-commit-and-pr", new[] { "repo:read", "checks:read" } + }; + yield return new object[] + { + "PC-OBS-DDOG", ProviderCapability.Observability, "Datadog", ProviderPortName.ObservabilityPort, + AclDirection.Inbound, "provider-telemetry", new[] { "metrics:read", "traces:read" } + }; + } + + [Theory] + [MemberData(nameof(CanonicalConnections))] + public void Create_WithCanonicalCatalog_ShouldSucceedAndDeriveCanonicalPort( + string code, string capability, string provider, string expectedPort, + string direction, string fieldOwnership, string[] scopes) + { + var acl = ProviderAcl.Create(direction, fieldOwnership, scopes).Value; + + var result = ProviderConnection.Create(_tenantId, code, capability, provider, acl, _userId); + + result.IsSuccess.Should().BeTrue(); + var connection = result.Value; + connection.TenantId.Should().Be(_tenantId); + connection.Code.Should().Be(code); + connection.Capability.Should().Be(capability); + connection.Provider.Should().Be(provider); + connection.Port.Should().Be(expectedPort); + connection.Status.Should().Be(ProviderConnectionStatus.Registered); + connection.Acl.Direction.Should().Be(direction); + connection.Acl.Scopes.Should().Equal(scopes); + } + + [Fact] + public void Create_WithInvalidCapability_ShouldReturnFailure() + { + var acl = ProviderAcl.Create(AclDirection.Inbound, "owner", new[] { "x:read" }).Value; + + var result = ProviderConnection.Create(_tenantId, "PC-BAD", "not-a-capability", "Whatever", acl, _userId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("ProviderConnection.InvalidCapability"); + } + + [Fact] + public void Connect_FromRegistered_ShouldTransitionToConnected() + { + var connection = NewConnection(); + + var result = connection.Connect(); + + result.IsSuccess.Should().BeTrue(); + connection.Status.Should().Be(ProviderConnectionStatus.Connected); + } + + [Fact] + public void Connect_WhenAlreadyConnected_ShouldReturnFailure() + { + var connection = NewConnection(); + connection.Connect(); + + var result = connection.Connect(); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("ProviderConnection.AlreadyConnected"); + } + + [Fact] + public void Disconnect_FromConnected_ShouldTransitionToDisconnected() + { + var connection = NewConnection(); + connection.Connect(); + + var result = connection.Disconnect(); + + result.IsSuccess.Should().BeTrue(); + connection.Status.Should().Be(ProviderConnectionStatus.Disconnected); + } + + [Fact] + public void MarkError_ShouldTransitionToErrorAndCaptureReason() + { + var connection = NewConnection(); + + var result = connection.MarkError("token expired"); + + result.IsSuccess.Should().BeTrue(); + connection.Status.Should().Be(ProviderConnectionStatus.Error); + connection.LastError.Should().Be("token expired"); + } + + [Fact] + public void MarkError_WithEmptyReason_ShouldReturnFailure() + { + var connection = NewConnection(); + + var result = connection.MarkError(" "); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("ProviderConnection.EmptyErrorReason"); + } + + [Fact] + public void UpdateAcl_ShouldReplaceAcl() + { + var connection = NewConnection(); + var newAcl = ProviderAcl.Create(AclDirection.Outbound, "tracker-owned", new[] { "work:write" }).Value; + + var result = connection.UpdateAcl(newAcl); + + result.IsSuccess.Should().BeTrue(); + connection.Acl.Direction.Should().Be(AclDirection.Outbound); + connection.Acl.Scopes.Should().Equal("work:write"); + } + + [Fact] + public void ConnectAfterDisconnect_ShouldReturnToConnected() + { + var connection = NewConnection(); + connection.Connect(); + connection.Disconnect(); + + var result = connection.Connect(); + + result.IsSuccess.Should().BeTrue(); + connection.Status.Should().Be(ProviderConnectionStatus.Connected); + } + + private ProviderConnection NewConnection() + { + var acl = ProviderAcl.Create(AclDirection.TwoWay, "external-title-and-status", + new[] { "work:read", "work:write" }).Value; + return ProviderConnection.Create( + _tenantId, "PC-WORK-JIRA", ProviderCapability.WorkManagement, "Jira", acl, _userId).Value; + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProviderConnectionMapperTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProviderConnectionMapperTests.cs new file mode 100644 index 00000000..e9486f49 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProviderConnectionMapperTests.cs @@ -0,0 +1,53 @@ +using Xunit; +using FluentAssertions; +using Tracker.Domain.Integration.ProviderConnection; +using Tracker.Infrastructure.Persistence.Integration; + +namespace Tracker.Tests.Infrastructure.Persistence; + +/// +/// Pure mapper round-trip (no Postgres): the repository flattens the ProviderConnection ACL into +/// two text columns plus a jsonb scope list and reconstitutes it. Verifies the ACL fields and the +/// scope list survive the serialize/deserialize hop. +/// +public class ProviderConnectionMapperTests +{ + [Fact] + public void RoundTrip_ShouldPreserveAclAndScopes() + { + var tenantId = Guid.NewGuid(); + var acl = ProviderAcl.Create(AclDirection.TwoWay, "external-title-and-status", + new[] { "work:read", "work:write" }).Value; + var original = ProviderConnection.Create( + tenantId, "PC-WORK-JIRA", ProviderCapability.WorkManagement, "Jira", acl, Guid.NewGuid()).Value; + + var record = PostgreSqlProviderConnectionRepository.MapToRecord(original); + var restored = PostgreSqlProviderConnectionRepository.MapToDomain(record); + + restored.Id.Should().Be(original.Id); + restored.TenantId.Should().Be(tenantId); + restored.Code.Should().Be("PC-WORK-JIRA"); + restored.Capability.Should().Be(ProviderCapability.WorkManagement); + restored.Provider.Should().Be("Jira"); + restored.Port.Should().Be(ProviderPortName.WorkItemPort); + restored.Status.Should().Be(ProviderConnectionStatus.Registered); + restored.Acl.Direction.Should().Be(AclDirection.TwoWay); + restored.Acl.FieldOwnership.Should().Be("external-title-and-status"); + restored.Acl.Scopes.Should().Equal("work:read", "work:write"); + restored.Acl.Should().Be(original.Acl); + } + + [Fact] + public void RoundTrip_WithEmptyScopes_ShouldPreserveEmptyList() + { + var acl = ProviderAcl.Create(AclDirection.Inbound, "provider-telemetry", Array.Empty()).Value; + var original = ProviderConnection.Create( + Guid.NewGuid(), "PC-OBS-DDOG", ProviderCapability.Observability, "Datadog", acl, Guid.NewGuid()).Value; + + var record = PostgreSqlProviderConnectionRepository.MapToRecord(original); + var restored = PostgreSqlProviderConnectionRepository.MapToDomain(record); + + restored.Acl.Scopes.Should().BeEmpty(); + restored.Port.Should().Be(ProviderPortName.ObservabilityPort); + } +}