feat: implement strangler-fig-demo Session 1 (backend + smoke script)
Builds the four-seam, three-write-path reference demo backend: case-framework (seam D stand-in), legacy-backend/frontend (SQL Server, seams A/B/C targets), and new-backend (Domain/Application/Infrastructure.*/Api implementing the source resolver, take/release-ownership, write-through translator, and owned assessment flow), wired together via docker-compose with a plain placeholder frontend standing in for the Angular portal until Session 2. All 11 Architecture.Tests pass and scripts/smoke.sh passes end-to-end against a fresh `docker compose up`, covering acceptance criteria 1-3 and 7-22. Fixes two real domain bugs found only once the stack ran for real: the BSN eleven-proof checksum trivially passes all-zero digits, and the adoption mapper silently treated a partial legacy address as absent instead of failing loudly. Also fixes several environment-specific integration issues (rootless Podman/SELinux bind-mount permissions, a buildah NuGet layer-caching bug, SqlClient's invariant-globalization incompatibility, and an nginx path-prefix mismatch for the legacy frontend). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY src/New.Domain/New.Domain.csproj src/New.Domain/
|
||||
COPY src/New.Application/New.Application.csproj src/New.Application/
|
||||
COPY src/New.Infrastructure.Persistence/New.Infrastructure.Persistence.csproj src/New.Infrastructure.Persistence/
|
||||
COPY src/New.Infrastructure.Legacy/New.Infrastructure.Legacy.csproj src/New.Infrastructure.Legacy/
|
||||
COPY src/New.Infrastructure.CaseFramework/New.Infrastructure.CaseFramework.csproj src/New.Infrastructure.CaseFramework/
|
||||
COPY src/New.Api/New.Api.csproj src/New.Api/
|
||||
|
||||
COPY src/ src/
|
||||
# restore+publish combined in one RUN/layer: podman/buildah has a known issue
|
||||
# where the NuGet global-packages cache uses hardlinks that break when a
|
||||
# restore layer and a later --no-restore publish layer are committed
|
||||
# separately, surfacing as a false "package not found" error.
|
||||
RUN dotnet restore src/New.Api/New.Api.csproj && \
|
||||
dotnet publish src/New.Api/New.Api.csproj -c Release -o /app --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "New.Api.dll"]
|
||||
@@ -0,0 +1,54 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the presentation-only `actions`/`seams` blocks on top of a
|
||||
/// CaseDetail read model. This is deliberately New.Api's job, not the
|
||||
/// resolver's or either source's - it's about which endpoints exist, which
|
||||
/// is an API-shape concern, not a data-source concern.
|
||||
/// </summary>
|
||||
internal static class CaseDetailResponseFactory
|
||||
{
|
||||
public static CaseDetailResponse From(CaseDetail detail)
|
||||
{
|
||||
var actions = detail.Origin == WorklistOrigin.Legacy
|
||||
? BuildLegacyActions(detail.LegacyAanvraagId!.Value)
|
||||
: BuildOwnedActions(detail.RegistrationApplicationId!.Value);
|
||||
|
||||
var seams = detail.Origin == WorklistOrigin.Legacy
|
||||
? new Dictionary<string, string?> { ["aanvrager"] = "legacy-backend", ["procestijdlijn"] = null }
|
||||
: new Dictionary<string, string?> { ["aanvrager"] = "owned", ["procestijdlijn"] = "case-framework-timeline" };
|
||||
|
||||
return new CaseDetailResponse(
|
||||
detail.Origin.ToString(),
|
||||
detail.LegacyAanvraagId,
|
||||
detail.RegistrationApplicationId,
|
||||
detail.Surname,
|
||||
detail.Initials,
|
||||
detail.Bsn,
|
||||
AddressResponse.From(detail.Address),
|
||||
detail.Email,
|
||||
detail.Phone,
|
||||
detail.PreferredChannel,
|
||||
detail.DiplomaCode,
|
||||
detail.DiplomaCountryOfIssue,
|
||||
detail.DiplomaIssuedOn,
|
||||
detail.ReceivedOn,
|
||||
AssessmentResponse.From(detail.Assessment),
|
||||
detail.ProcessStatus,
|
||||
detail.LastModifiedAt,
|
||||
actions,
|
||||
seams);
|
||||
}
|
||||
|
||||
private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new(
|
||||
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
|
||||
RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"),
|
||||
TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"));
|
||||
|
||||
private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new(
|
||||
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
|
||||
RecordAssessment: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/assessment"),
|
||||
ReleaseOwnership: new ActionLink("transition", $"/api/worklist/owned/{registrationApplicationId}/ownership"));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using New.Application.WriteThrough;
|
||||
using New.Application.Worklist;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Api.Contracts;
|
||||
|
||||
public sealed record AddressRequest(string Street, string Number, string PostalCode, string City)
|
||||
{
|
||||
public AddressData ToData() => new(Street, Number, PostalCode, City);
|
||||
}
|
||||
|
||||
public sealed record ApplicantDetailsRequest(
|
||||
string Surname,
|
||||
string Initials,
|
||||
AddressRequest? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel)
|
||||
{
|
||||
public ApplicantDetailsCommand ToCommand() => new(Surname, Initials, Address?.ToData(), Email, Phone, PreferredChannel);
|
||||
}
|
||||
|
||||
public sealed record RecordAssessmentRequest(
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
string Outcome,
|
||||
string? RejectionCategory,
|
||||
string Motivation);
|
||||
|
||||
public sealed record FieldErrorResponse(string Field, string Message, string? Detail = null)
|
||||
{
|
||||
public static FieldErrorResponse From(PortalFieldError error) => new(error.Field, error.Message, error.Detail);
|
||||
}
|
||||
|
||||
public sealed record ErrorsResponse(IReadOnlyList<FieldErrorResponse> Errors)
|
||||
{
|
||||
public static ErrorsResponse From(IReadOnlyList<PortalFieldError> errors) =>
|
||||
new(errors.Select(FieldErrorResponse.From).ToList());
|
||||
}
|
||||
|
||||
public sealed record InvariantViolationResponse(string Invariant, string Message);
|
||||
|
||||
public sealed record MessageResponse(string Message);
|
||||
|
||||
public sealed record RecordAssessmentResponse(bool ClosurePending);
|
||||
@@ -0,0 +1,83 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Api.Contracts;
|
||||
|
||||
public sealed record WorklistItemResponse(
|
||||
string Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
DateOnly ReceivedOn,
|
||||
string Bucket,
|
||||
string? AssessmentOutcome,
|
||||
string? ProcessStatus)
|
||||
{
|
||||
public static WorklistItemResponse From(WorklistItem item) => new(
|
||||
item.Origin.ToString(),
|
||||
item.LegacyAanvraagId,
|
||||
item.RegistrationApplicationId,
|
||||
item.Surname,
|
||||
item.Initials,
|
||||
item.Bsn,
|
||||
item.ReceivedOn,
|
||||
item.Bucket,
|
||||
item.AssessmentOutcome,
|
||||
item.ProcessStatus);
|
||||
}
|
||||
|
||||
public sealed record WorklistPageResponse(
|
||||
IReadOnlyList<WorklistItemResponse> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalCount);
|
||||
|
||||
public sealed record ActionLink(string Mode, string Href);
|
||||
|
||||
public sealed record CaseDetailActions(
|
||||
ActionLink EditApplicantDetails,
|
||||
ActionLink RecordAssessment,
|
||||
ActionLink? TakeOwnership = null,
|
||||
ActionLink? ReleaseOwnership = null);
|
||||
|
||||
public sealed record AddressResponse(string Street, string Number, string PostalCode, string City)
|
||||
{
|
||||
public static AddressResponse? From(AddressData? data) =>
|
||||
data is null ? null : new AddressResponse(data.Street, data.Number, data.PostalCode, data.City);
|
||||
}
|
||||
|
||||
public sealed record AssessmentResponse(
|
||||
string Outcome,
|
||||
string Motivation,
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
string? RejectionCategory,
|
||||
DateOnly DecidedOn)
|
||||
{
|
||||
public static AssessmentResponse? From(AssessmentData? data) =>
|
||||
data is null
|
||||
? null
|
||||
: new AssessmentResponse(data.Outcome, data.Motivation, data.VerifiedItems, data.ExceptionReason, data.RejectionCategory, data.DecidedOn);
|
||||
}
|
||||
|
||||
public sealed record CaseDetailResponse(
|
||||
string Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
AddressResponse? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel,
|
||||
string DiplomaCode,
|
||||
string DiplomaCountryOfIssue,
|
||||
DateOnly DiplomaIssuedOn,
|
||||
DateOnly ReceivedOn,
|
||||
AssessmentResponse? Assessment,
|
||||
string? ProcessStatus,
|
||||
DateTimeOffset? LastModifiedAt,
|
||||
CaseDetailActions Actions,
|
||||
IReadOnlyDictionary<string, string?> Seams);
|
||||
@@ -0,0 +1,46 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Assessments;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class AssessmentEndpoints
|
||||
{
|
||||
public static void MapAssessmentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/worklist/owned/{registrationApplicationId:guid}/assessment", RecordAssessmentAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecordAssessmentAsync(
|
||||
Guid registrationApplicationId, RecordAssessmentRequest request, RecordOwnedAssessmentHandler handler, CancellationToken ct)
|
||||
{
|
||||
AssessmentOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = Enum.Parse<AssessmentOutcome>(request.Outcome, ignoreCase: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Results.UnprocessableEntity(new InvariantViolationResponse(
|
||||
"Assessment.UnrecognizedOutcome", $"'{request.Outcome}' is not a recognized outcome (expected 'Approved' or 'Rejected')."));
|
||||
}
|
||||
|
||||
var command = new RecordAssessmentCommand(request.VerifiedItems, request.ExceptionReason, outcome, request.RejectionCategory, request.Motivation);
|
||||
|
||||
// Re-validates everything server-side via the domain's own
|
||||
// RecordAssessment, regardless of what the client already checked.
|
||||
var result = await handler.HandleAsync(registrationApplicationId, command, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
// Spec allows either a 204 with a body, or 204 plus a follow-up
|
||||
// field - since an HTTP 204 cannot carry a body, we use 200 with
|
||||
// a small { closurePending } body to actually convey it.
|
||||
RecordAssessmentResultKind.Success => Results.Ok(new RecordAssessmentResponse(result.ClosurePending)),
|
||||
RecordAssessmentResultKind.NotFound => Results.NotFound(),
|
||||
RecordAssessmentResultKind.InvariantViolation => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Ports;
|
||||
using New.Application.WriteThrough;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class DetailsEndpoints
|
||||
{
|
||||
public static void MapDetailsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPut("/api/worklist/legacy/{aanvraagId:int}/details", UpdateLegacyDetailsAsync);
|
||||
app.MapPut("/api/worklist/owned/{registrationApplicationId:guid}/details", UpdateOwnedDetailsAsync);
|
||||
}
|
||||
|
||||
// Seam B: write-through. New.Api's own job here is limited to translating
|
||||
// the HTTP request into the command and the outcome into an HTTP
|
||||
// response - the actual translation to/from legacy's shape (and the "no
|
||||
// business rules" constraint) lives in New.Infrastructure.Legacy.
|
||||
private static async Task<IResult> UpdateLegacyDetailsAsync(
|
||||
int aanvraagId, ApplicantDetailsRequest request, ILegacyCaseGateway gateway, CancellationToken ct)
|
||||
{
|
||||
var outcome = await gateway.UpdateDetailsAsync(aanvraagId, request.ToCommand(), ct);
|
||||
|
||||
return outcome.Kind switch
|
||||
{
|
||||
WriteThroughOutcomeKind.Success => Results.NoContent(),
|
||||
WriteThroughOutcomeKind.NotFound => Results.NotFound(),
|
||||
WriteThroughOutcomeKind.Conflict => Results.Conflict(new MessageResponse("This aanvraag has already been migrated and can no longer be edited in legacy.")),
|
||||
WriteThroughOutcomeKind.ValidationFailed => Results.BadRequest(ErrorsResponse.From(outcome.Errors!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOwnedDetailsAsync(
|
||||
Guid registrationApplicationId, ApplicantDetailsRequest request, UpdateOwnedApplicantDetailsHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(registrationApplicationId, request.ToCommand(), ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
UpdateOwnedApplicantDetailsResultKind.Success => Results.NoContent(),
|
||||
UpdateOwnedApplicantDetailsResultKind.NotFound => Results.NotFound(),
|
||||
UpdateOwnedApplicantDetailsResultKind.InvariantViolation => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using New.Infrastructure.Legacy;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class DiagnosticsEndpoints
|
||||
{
|
||||
public static void MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/diagnostics/legacy-call-count", (LegacyCallCounter counter) =>
|
||||
Results.Ok(new { count = counter.Count }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Ownership;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class OwnershipEndpoints
|
||||
{
|
||||
public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync);
|
||||
app.MapDelete("/api/worklist/owned/{registrationApplicationId:guid}/ownership", ReleaseOwnershipAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TakeOwnershipAsync(int aanvraagId, TakeOwnershipHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(aanvraagId, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
TakeOwnershipResultKind.Success => Results.Created(
|
||||
$"/api/worklist/owned/{result.RegistrationApplicationId}",
|
||||
new { registrationApplicationId = result.RegistrationApplicationId }),
|
||||
TakeOwnershipResultKind.AlreadyOwned => Results.Conflict(new MessageResponse("This aanvraag has already been taken into ownership.")),
|
||||
TakeOwnershipResultKind.LegacyCaseNotFound => Results.NotFound(),
|
||||
TakeOwnershipResultKind.MappingFailed => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(registrationApplicationId, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
ReleaseOwnershipResultKind.Success => Results.NoContent(),
|
||||
ReleaseOwnershipResultKind.NotOwned => Results.NotFound(),
|
||||
ReleaseOwnershipResultKind.Conflict => Results.Conflict(new MessageResponse(result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class WorklistEndpoints
|
||||
{
|
||||
private const int PageSize = 10;
|
||||
|
||||
public static void MapWorklistEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/worklist", GetWorklistAsync);
|
||||
app.MapGet("/api/worklist/legacy/{aanvraagId:int}", GetLegacyDetailAsync);
|
||||
app.MapGet("/api/worklist/owned/{registrationApplicationId:guid}", GetOwnedDetailAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWorklistAsync(
|
||||
string? bucket,
|
||||
string? origin,
|
||||
string? search,
|
||||
string? sort,
|
||||
int? page,
|
||||
IOwnedWorklistReader ownedReader,
|
||||
ILegacyWorklistReader legacyReader,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Fetch both sources fully and merge/sort/page in memory here - a
|
||||
// known, deliberate shortcut for this demo's seed volumes (12 legacy
|
||||
// + 5 owned rows). A production version would need keyset pagination
|
||||
// per source or a materialized index instead.
|
||||
var ownedItems = await ownedReader.ListAsync(ct);
|
||||
var legacyItems = await legacyReader.ListAsync(ct);
|
||||
|
||||
// A legacy row already taken into ownership is now represented by
|
||||
// its owned counterpart - excluded here so it doesn't show up twice.
|
||||
var merged = ownedItems.Concat(legacyItems.Where(i => !i.Migrated));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bucket))
|
||||
{
|
||||
merged = merged.Where(i => string.Equals(i.Bucket, bucket, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(origin) && Enum.TryParse<WorklistOrigin>(origin, ignoreCase: true, out var parsedOrigin))
|
||||
{
|
||||
merged = merged.Where(i => i.Origin == parsedOrigin);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
merged = merged.Where(i =>
|
||||
i.Surname.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Initials.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Bsn.Contains(search, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
merged = sort switch
|
||||
{
|
||||
"surname" => merged.OrderBy(i => i.Surname),
|
||||
"-surname" => merged.OrderByDescending(i => i.Surname),
|
||||
"receivedOn" => merged.OrderBy(i => i.ReceivedOn),
|
||||
_ => merged.OrderByDescending(i => i.ReceivedOn), // default: "-receivedOn"
|
||||
};
|
||||
|
||||
var all = merged.ToList();
|
||||
var pageNumber = page is > 0 ? page.Value : 1;
|
||||
var pageItems = all.Skip((pageNumber - 1) * PageSize).Take(PageSize).Select(WorklistItemResponse.From).ToList();
|
||||
|
||||
return Results.Ok(new WorklistPageResponse(pageItems, pageNumber, PageSize, all.Count));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLegacyDetailAsync(int aanvraagId, IApplicationSource resolver, CancellationToken ct)
|
||||
{
|
||||
var detail = await resolver.GetByLegacyIdAsync(aanvraagId, ct);
|
||||
return detail is null ? Results.NotFound() : Results.Ok(CaseDetailResponseFactory.From(detail));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOwnedDetailAsync(
|
||||
Guid registrationApplicationId,
|
||||
New.Infrastructure.Persistence.OwnedApplicationSource owned,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var detail = await owned.GetAsync(registrationApplicationId, ct);
|
||||
return detail is null ? Results.NotFound() : Results.Ok(CaseDetailResponseFactory.From(detail));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Api</RootNamespace>
|
||||
<UserSecretsId>new-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Composition root: the only project allowed to reference every New.* project. -->
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
<ProjectReference Include="..\New.Infrastructure.Persistence\New.Infrastructure.Persistence.csproj" />
|
||||
<ProjectReference Include="..\New.Infrastructure.Legacy\New.Infrastructure.Legacy.csproj" />
|
||||
<ProjectReference Include="..\New.Infrastructure.CaseFramework\New.Infrastructure.CaseFramework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Api.Endpoints;
|
||||
using New.Api.Resolution;
|
||||
using New.Api.Seeding;
|
||||
using New.Application.Assessments;
|
||||
using New.Application.Ownership;
|
||||
using New.Application.Ports;
|
||||
using New.Application.WriteThrough;
|
||||
using New.Infrastructure.CaseFramework;
|
||||
using New.Infrastructure.Legacy;
|
||||
using New.Infrastructure.Persistence;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddPersistenceInfrastructure(builder.Configuration);
|
||||
builder.Services.AddLegacyInfrastructure(builder.Configuration);
|
||||
builder.Services.AddCaseFrameworkInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// The only registration in the whole solution naming both "source" types -
|
||||
// see ApplicationSourceResolver's remarks (Architecture.Tests rule 7).
|
||||
builder.Services.AddScoped<IApplicationSource, ApplicationSourceResolver>();
|
||||
|
||||
builder.Services.AddScoped<TakeOwnershipHandler>();
|
||||
builder.Services.AddScoped<ReleaseOwnershipHandler>();
|
||||
builder.Services.AddScoped<RecordOwnedAssessmentHandler>();
|
||||
builder.Services.AddScoped<UpdateOwnedApplicantDetailsHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<NewDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await OwnedApplicationSeeder.SeedAsync(app.Services);
|
||||
|
||||
app.MapWorklistEndpoints();
|
||||
app.MapDetailsEndpoints();
|
||||
app.MapOwnershipEndpoints();
|
||||
app.MapAssessmentEndpoints();
|
||||
app.MapDiagnosticsEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,33 @@
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
using New.Infrastructure.Legacy;
|
||||
using New.Infrastructure.Persistence;
|
||||
|
||||
namespace New.Api.Resolution;
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY type in the whole solution that references both
|
||||
/// <see cref="OwnedApplicationSource"/> and <see cref="LegacyCaseSource"/> -
|
||||
/// Architecture.Tests rule 7 asserts exactly that. Every other type that
|
||||
/// needs case data reaches it through a port (IApplicationSource for
|
||||
/// by-id resolution, or IOwnedWorklistReader/ILegacyWorklistReader for the
|
||||
/// merged worklist listing - deliberately different types, see those
|
||||
/// interfaces' remarks) without ever knowing there are two sources at all.
|
||||
/// This is the seam-hiding point of the whole "strangler fig" design: a
|
||||
/// legacy aanvraagId keeps working transparently after adoption, because
|
||||
/// this resolver - and only this resolver - knows to check the ownership
|
||||
/// registry first and redirect to the owned copy when present.
|
||||
/// </summary>
|
||||
internal sealed class ApplicationSourceResolver(
|
||||
OwnedApplicationSource owned,
|
||||
LegacyCaseSource legacy,
|
||||
IOwnershipRegistry registry) : IApplicationSource
|
||||
{
|
||||
public async Task<CaseDetail?> GetByLegacyIdAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var ownedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
||||
return ownedId is null
|
||||
? await legacy.GetAsync(aanvraagId, ct) // seam A
|
||||
: await owned.GetAsync(ownedId.Value, ct); // owned
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
using New.Infrastructure.Persistence;
|
||||
|
||||
namespace New.Api.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent startup seeder for the 5 natively-owned applications
|
||||
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
|
||||
/// README click-through can reference them directly). REG-2026-0002 is
|
||||
/// seeded with an open case-framework task on purpose, so a later closure
|
||||
/// request against it demonstrates the §6 conflict (409, decision stands).
|
||||
/// </summary>
|
||||
internal static class OwnedApplicationSeeder
|
||||
{
|
||||
private const string CaseTypeCode = "RegistrationApplication";
|
||||
|
||||
private sealed record Seed(
|
||||
Guid Id,
|
||||
string ExternalReference,
|
||||
string Bsn,
|
||||
string Surname,
|
||||
string Initials,
|
||||
DateOnly ReceivedOn,
|
||||
string DiplomaCode,
|
||||
string DiplomaCountry,
|
||||
DateOnly DiplomaIssuedOn,
|
||||
bool OpenTask,
|
||||
AssessmentOutcome? Outcome,
|
||||
string? RejectionCategory);
|
||||
|
||||
private static readonly Seed[] Seeds =
|
||||
[
|
||||
new(new Guid("00000000-0000-0000-0000-000000000001"), "REG-2026-0001", "123456782", "de Groot", "A.",
|
||||
new DateOnly(2025, 9, 12), "MSC-INFO", "DE", new DateOnly(2024, 7, 1), false, AssessmentOutcome.Approved, null),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000002"), "REG-2026-0002", "234567892", "Hendriks", "M.J.",
|
||||
new DateOnly(2025, 10, 3), "BSC-ENG", "BE", new DateOnly(2023, 6, 15), true, null, null),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000003"), "REG-2026-0003", "345678904", "Kuipers", "R.",
|
||||
new DateOnly(2025, 11, 20), "MSC-LAW", "FR", new DateOnly(2022, 3, 10), false, AssessmentOutcome.Rejected, "NietErkend"),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000004"), "REG-2026-0004", "456789017", "Postma", "S.E.",
|
||||
new DateOnly(2026, 1, 5), "BSC-MED", "ES", new DateOnly(2021, 9, 1), false, AssessmentOutcome.Approved, null),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000005"), "REG-2026-0005", "567890120", "van Dijk", "T.",
|
||||
new DateOnly(2026, 2, 14), "MSC-ARCH", "IT", new DateOnly(2020, 5, 20), false, null, null),
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(IServiceProvider services, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<NewDbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("OwnedApplicationSeeder");
|
||||
|
||||
if (await db.RegistrationApplications.AnyAsync(ct))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IRegistrationApplicationRepository>();
|
||||
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
var caseFramework = scope.ServiceProvider.GetRequiredService<ICaseFrameworkGateway>();
|
||||
|
||||
foreach (var seed in Seeds)
|
||||
{
|
||||
var application = RegistrationApplication.Create(
|
||||
seed.Id,
|
||||
new Bsn(seed.Bsn),
|
||||
new PersonName(seed.Surname, seed.Initials),
|
||||
correspondenceAddress: null,
|
||||
new ContactDetails(email: null, phone: null, CorrespondenceChannel.Post),
|
||||
new DiplomaEvidence(seed.DiplomaCode, seed.DiplomaCountry, seed.DiplomaIssuedOn),
|
||||
seed.ReceivedOn);
|
||||
|
||||
if (seed.Outcome is { } outcome)
|
||||
{
|
||||
var motivation = outcome == AssessmentOutcome.Approved
|
||||
? "Alle overgelegde bewijsstukken zijn gecontroleerd en in orde bevonden."
|
||||
: "Het overgelegde diploma wordt niet erkend door de bevoegde autoriteit.";
|
||||
|
||||
application.RecordAssessment(
|
||||
outcome,
|
||||
motivation,
|
||||
verifiedItems: ["document", "land", "datum"],
|
||||
exceptionReason: null,
|
||||
seed.RejectionCategory,
|
||||
seed.ReceivedOn.AddDays(14));
|
||||
}
|
||||
|
||||
// case-framework may still be starting up when this runs -
|
||||
// depends_on only guarantees the container process started, not
|
||||
// that it's ready to accept connections. Retry with backoff
|
||||
// rather than crashing the whole API on a slow neighbor.
|
||||
var created = await CreateCaseWithRetryAsync(caseFramework, seed.ExternalReference, seed.Surname, seed.Initials, logger, ct);
|
||||
|
||||
application.AttachCaseReference(new CaseReference(created.CaseId, seed.ExternalReference, created.ProcessStatus));
|
||||
|
||||
if (seed.OpenTask)
|
||||
{
|
||||
await caseFramework.CreateTaskAsync(created.CaseId, "ADMIN-CLOSURE", "Administratieve afronding", ct);
|
||||
}
|
||||
|
||||
await repository.AddAsync(application, ct);
|
||||
}
|
||||
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task<CaseCreated> CreateCaseWithRetryAsync(
|
||||
ICaseFrameworkGateway gateway, string externalReference, string surname, string initials, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
const int maxAttempts = 10;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.CreateCaseAsync(CaseTypeCode, externalReference, [$"{surname} {initials}"], ct);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
logger.LogWarning(ex, "case-framework not ready yet while seeding {ExternalReference} (attempt {Attempt}/{MaxAttempts}), retrying...",
|
||||
externalReference, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Application.Assessments;
|
||||
|
||||
public sealed record RecordAssessmentCommand(
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
AssessmentOutcome Outcome,
|
||||
string? RejectionCategory,
|
||||
string Motivation);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace New.Application.Assessments;
|
||||
|
||||
public enum RecordAssessmentResultKind
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
InvariantViolation,
|
||||
}
|
||||
|
||||
public sealed record RecordAssessmentResult(
|
||||
RecordAssessmentResultKind Kind,
|
||||
bool ClosurePending = false,
|
||||
string? Invariant = null,
|
||||
string? Message = null)
|
||||
{
|
||||
public static RecordAssessmentResult Success(bool closurePending) =>
|
||||
new(RecordAssessmentResultKind.Success, ClosurePending: closurePending);
|
||||
|
||||
public static readonly RecordAssessmentResult NotFound = new(RecordAssessmentResultKind.NotFound);
|
||||
|
||||
public static RecordAssessmentResult InvariantViolation(string invariant, string message) =>
|
||||
new(RecordAssessmentResultKind.InvariantViolation, Invariant: invariant, Message: message);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Application.Assessments;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates POST /api/worklist/owned/{id}/assessment. Re-validates
|
||||
/// everything server-side via the domain's own RecordAssessment method,
|
||||
/// regardless of what the client already checked.
|
||||
/// </summary>
|
||||
public sealed class RecordOwnedAssessmentHandler(
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork,
|
||||
IOwnershipRegistry registry,
|
||||
ICaseFrameworkGateway caseFrameworkGateway,
|
||||
TimeProvider clock)
|
||||
{
|
||||
public async Task<RecordAssessmentResult> HandleAsync(Guid registrationApplicationId, RecordAssessmentCommand command, CancellationToken ct)
|
||||
{
|
||||
var application = await repository.GetAsync(registrationApplicationId, ct);
|
||||
if (application is null)
|
||||
{
|
||||
return RecordAssessmentResult.NotFound;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
application.RecordAssessment(
|
||||
command.Outcome,
|
||||
command.Motivation,
|
||||
command.VerifiedItems,
|
||||
command.ExceptionReason,
|
||||
command.RejectionCategory,
|
||||
DateOnly.FromDateTime(clock.GetUtcNow().Date));
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return RecordAssessmentResult.InvariantViolation(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
// One transaction for the assessment write and the domain_writes_since
|
||||
// bump that gates ownership release.
|
||||
await registry.IncrementDomainWritesAsync(registrationApplicationId, ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
var closurePending = false;
|
||||
if (application.Case is not null)
|
||||
{
|
||||
// A 409 (open task) here is expected and fine - the assessment
|
||||
// already succeeded above and is NOT rolled back for it; we just
|
||||
// report that closure is pending.
|
||||
var closed = await caseFrameworkGateway.RequestClosureAsync(application.Case.FrameworkCaseId, ct);
|
||||
closurePending = !closed;
|
||||
}
|
||||
|
||||
return RecordAssessmentResult.Success(closurePending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Application</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Logging abstractions only - no concrete logging provider, no infra. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Application layer: ports (interfaces) and orchestration only. No EF Core,
|
||||
no HttpClient, no infrastructure project references - handlers here talk
|
||||
to the outside world exclusively through the ports defined in this project.
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
using New.Application.Ports;
|
||||
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates "release ownership" (DELETE /api/worklist/owned/{id}/ownership).
|
||||
/// The framework case is deliberately left as-is on release - a logged orphan,
|
||||
/// not cleaned up, per the spec.
|
||||
/// </summary>
|
||||
public sealed class ReleaseOwnershipHandler(
|
||||
IOwnershipRegistry registry,
|
||||
ILegacyCaseGateway legacyGateway,
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
public async Task<ReleaseOwnershipResult> HandleAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var record = await registry.GetAsync(registrationApplicationId, ct);
|
||||
if (record is null)
|
||||
{
|
||||
return ReleaseOwnershipResult.NotOwned;
|
||||
}
|
||||
|
||||
// Releasing would discard un-synced edits made through the owned path
|
||||
// since adoption - refuse rather than silently lose them.
|
||||
if (record.DomainWritesSince > 0)
|
||||
{
|
||||
return ReleaseOwnershipResult.Conflict(
|
||||
"Releasing ownership would discard un-synced edits made since this case was taken into ownership.");
|
||||
}
|
||||
|
||||
await legacyGateway.SetMigratedFlagAsync(record.LegacyAanvraagId, migrated: false, ct);
|
||||
|
||||
var application = await repository.GetAsync(registrationApplicationId, ct);
|
||||
if (application is not null)
|
||||
{
|
||||
await repository.RemoveAsync(application, ct);
|
||||
}
|
||||
|
||||
await registry.RemoveAsync(registrationApplicationId, ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
return ReleaseOwnershipResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
public enum ReleaseOwnershipResultKind
|
||||
{
|
||||
Success,
|
||||
NotOwned,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
public sealed record ReleaseOwnershipResult(ReleaseOwnershipResultKind Kind, string? Message = null)
|
||||
{
|
||||
public static readonly ReleaseOwnershipResult Success = new(ReleaseOwnershipResultKind.Success);
|
||||
public static readonly ReleaseOwnershipResult NotOwned = new(ReleaseOwnershipResultKind.NotOwned);
|
||||
|
||||
public static ReleaseOwnershipResult Conflict(string message) =>
|
||||
new(ReleaseOwnershipResultKind.Conflict, message);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates "take ownership" of a legacy case (POST
|
||||
/// /api/worklist/legacy/{aanvraagId}/take-ownership). References only ports -
|
||||
/// see Architecture.Tests rule 8 - never any New.Infrastructure.* concrete
|
||||
/// type, so this handler can be unit-tested (if this demo had a test suite
|
||||
/// for it) against fakes with zero HTTP/DB involved.
|
||||
///
|
||||
/// The step order below is load-bearing, not incidental - see the comment on
|
||||
/// each step for why it can't be reordered.
|
||||
/// </summary>
|
||||
public sealed class TakeOwnershipHandler(
|
||||
IOwnershipRegistry registry,
|
||||
ILegacyCaseGateway legacyGateway,
|
||||
ICaseFrameworkGateway caseFrameworkGateway,
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork,
|
||||
TimeProvider clock,
|
||||
ILogger<TakeOwnershipHandler> logger)
|
||||
{
|
||||
private const string CaseTypeCode = "RegistrationApplication";
|
||||
|
||||
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
// Step 1: guard against double adoption. Checked first and cheaply,
|
||||
// before touching legacy or case-framework at all.
|
||||
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
||||
if (existingOwnedId is not null)
|
||||
{
|
||||
return TakeOwnershipResult.AlreadyOwned;
|
||||
}
|
||||
|
||||
// Step 2: read the legacy case (seam A).
|
||||
// Step 3: map it to a RegistrationApplication. The mapper calls the
|
||||
// domain's normal validating constructors/factories, so any domain
|
||||
// exception here means the legacy data doesn't satisfy an invariant
|
||||
// the owned side requires. That must fail as a 422 naming the
|
||||
// failing invariant, and - critically - NOTHING is written anywhere:
|
||||
// no case-framework call, no persistence. FetchAndMapAsync lets the
|
||||
// domain exception surface as a thrown DomainInvariantViolationException,
|
||||
// which we catch here and translate, rather than swallowing it inside
|
||||
// the gateway - that keeps "nothing written on failure" trivially true,
|
||||
// since we simply haven't called anything else yet.
|
||||
LegacyFetchAndMapResult fetchResult;
|
||||
try
|
||||
{
|
||||
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
|
||||
{
|
||||
return TakeOwnershipResult.LegacyCaseNotFound;
|
||||
}
|
||||
|
||||
var application = fetchResult.Application;
|
||||
|
||||
// Step 4: THEN create the case-framework case - done before the local
|
||||
// transaction because it's an external system with no distributed
|
||||
// transaction available. A failure after this step leaves an
|
||||
// orphaned framework case (its externalReference matches no
|
||||
// aggregate) - detectable by a reconciliation query, not rolled back
|
||||
// here since case-framework has no compensating "delete case" seam.
|
||||
var created = await caseFrameworkGateway.CreateCaseAsync(
|
||||
CaseTypeCode,
|
||||
externalReference: application.RegistrationApplicationId.ToString(),
|
||||
participants: [$"{application.Applicant.Surname} {application.Applicant.Initials}"],
|
||||
ct);
|
||||
|
||||
application.AttachCaseReference(new CaseReference(
|
||||
created.CaseId,
|
||||
application.RegistrationApplicationId.ToString(),
|
||||
created.ProcessStatus));
|
||||
|
||||
// Step 5: persist the aggregate AND the legacy_ownership row in ONE
|
||||
// local transaction - both ports below are backed by the same scoped
|
||||
// DbContext, so the single SaveChangesAsync call is atomic across them.
|
||||
await repository.AddAsync(application, ct);
|
||||
await registry.RecordAsync(aanvraagId, application.RegistrationApplicationId, clock.GetUtcNow(), ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
// Step 6: LAST, flip legacy's migratie-vlag. A failure here leaves the
|
||||
// case owned locally but still writable in legacy (split-brain) -
|
||||
// detectable by a reconciliation query comparing legacy_ownership
|
||||
// against legacy's own migrated flags. We deliberately do not roll
|
||||
// back steps 4/5 if this fails: the aggregate is already the
|
||||
// system-of-record locally, and undoing that would be worse than a
|
||||
// detectable, reconcilable split-brain window.
|
||||
try
|
||||
{
|
||||
await legacyGateway.SetMigratedFlagAsync(aanvraagId, migrated: true, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to set legacy migratie-vlag for aanvraag {AanvraagId} after taking ownership as {RegistrationApplicationId}. " +
|
||||
"This is a split-brain condition: reconcile via legacy_ownership vs legacy's migrated flags.",
|
||||
aanvraagId,
|
||||
application.RegistrationApplicationId);
|
||||
}
|
||||
|
||||
return TakeOwnershipResult.Success(application.RegistrationApplicationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
public enum TakeOwnershipResultKind
|
||||
{
|
||||
Success,
|
||||
AlreadyOwned,
|
||||
LegacyCaseNotFound,
|
||||
MappingFailed,
|
||||
}
|
||||
|
||||
public sealed record TakeOwnershipResult(
|
||||
TakeOwnershipResultKind Kind,
|
||||
Guid? RegistrationApplicationId = null,
|
||||
string? Invariant = null,
|
||||
string? Message = null)
|
||||
{
|
||||
public static TakeOwnershipResult Success(Guid registrationApplicationId) =>
|
||||
new(TakeOwnershipResultKind.Success, RegistrationApplicationId: registrationApplicationId);
|
||||
|
||||
public static readonly TakeOwnershipResult AlreadyOwned = new(TakeOwnershipResultKind.AlreadyOwned);
|
||||
public static readonly TakeOwnershipResult LegacyCaseNotFound = new(TakeOwnershipResultKind.LegacyCaseNotFound);
|
||||
|
||||
public static TakeOwnershipResult MappingFailed(string invariant, string message) =>
|
||||
new(TakeOwnershipResultKind.MappingFailed, Invariant: invariant, Message: message);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a case by its legacy id transparently, regardless of whether it
|
||||
/// has been taken into ownership. Implemented by the (single) source
|
||||
/// resolver - see the composition root for why that type is the only one
|
||||
/// allowed to know both sources exist (Architecture.Tests rule 7).
|
||||
/// </summary>
|
||||
public interface IApplicationSource
|
||||
{
|
||||
Task<CaseDetail?> GetByLegacyIdAsync(int aanvraagId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace New.Application.Ports;
|
||||
|
||||
public sealed record CaseCreated(Guid CaseId, string? ProcessStatus);
|
||||
|
||||
public sealed record TaskCreated(Guid TaskId, bool Open);
|
||||
|
||||
/// <summary>Seam D: the case-framework client port (New.Infrastructure.CaseFramework implements this).</summary>
|
||||
public interface ICaseFrameworkGateway
|
||||
{
|
||||
Task<CaseCreated> CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList<string> participants, CancellationToken ct);
|
||||
|
||||
Task<string?> GetProcessStatusAsync(Guid caseId, CancellationToken ct);
|
||||
|
||||
Task<TaskCreated> CreateTaskAsync(Guid caseId, string code, string description, CancellationToken ct);
|
||||
|
||||
Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// POST .../closure-request. Returns true if the case closed, false if
|
||||
/// the framework returned 409 (an open task) - which is an expected,
|
||||
/// non-exceptional outcome for callers (e.g. the owned assessment flow
|
||||
/// treats it as "closure pending", not a failure).
|
||||
/// </summary>
|
||||
Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using New.Application.WriteThrough;
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
public enum LegacyFetchStatus
|
||||
{
|
||||
Found,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
public sealed record LegacyFetchAndMapResult(LegacyFetchStatus Status, RegistrationApplication? Application);
|
||||
|
||||
/// <summary>
|
||||
/// The legacy-facing operations needed by the take-ownership flow, the
|
||||
/// write-through edit seam, and ownership release - as opposed to
|
||||
/// <c>LegacyCaseSource</c> (seam A read, used only by the source resolver).
|
||||
/// Kept as a separate port/type from that read seam deliberately (see
|
||||
/// Architecture.Tests rule 7's remarks on the resolver's exclusivity).
|
||||
/// </summary>
|
||||
public interface ILegacyCaseGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the legacy case and maps it to a <see cref="RegistrationApplication"/>
|
||||
/// via the internal mapper. A domain exception during mapping propagates
|
||||
/// as-is (callers such as the take-ownership handler turn it into a 422) -
|
||||
/// this method itself never swallows mapping failures.
|
||||
/// </summary>
|
||||
Task<LegacyFetchAndMapResult> FetchAndMapAsync(int aanvraagId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Seam B: PUT .../gegevens. This is a pure translation - see
|
||||
/// LegacyDetailsWriteThroughTranslator for the "no business rules" comment.
|
||||
/// </summary>
|
||||
Task<WriteThroughOutcome> UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct);
|
||||
|
||||
/// <summary>PUT .../migratie-vlag. Used on take-ownership (true) and release-ownership (false).</summary>
|
||||
Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>Lists legacy applications (via seam A) for the merged worklist.</summary>
|
||||
public interface ILegacyWorklistReader
|
||||
{
|
||||
Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// Lists owned applications for the merged worklist. Deliberately a
|
||||
/// different port/type than whatever the source resolver uses to fetch a
|
||||
/// single owned case by id - see Architecture.Tests rule 7's remarks on the
|
||||
/// resolver being the only type that reaches into both sources.
|
||||
/// </summary>
|
||||
public interface IOwnedWorklistReader
|
||||
{
|
||||
Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>Read-model row of the `legacy_ownership` table.</summary>
|
||||
public sealed record OwnershipRecord(
|
||||
int LegacyAanvraagId,
|
||||
Guid RegistrationApplicationId,
|
||||
DateTimeOffset TakenOverAt,
|
||||
int DomainWritesSince);
|
||||
|
||||
/// <summary>
|
||||
/// The `legacy_ownership` table - which legacy aanvraagen have been taken
|
||||
/// into ownership, and how many domain writes have happened since (which
|
||||
/// gates whether ownership can be released again).
|
||||
/// </summary>
|
||||
public interface IOwnershipRegistry
|
||||
{
|
||||
/// <summary>Null if <paramref name="legacyAanvraagId"/> has not been taken into ownership.</summary>
|
||||
Task<Guid?> LookupOwnedIdAsync(int legacyAanvraagId, CancellationToken ct);
|
||||
|
||||
Task<OwnershipRecord?> GetAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages a new ownership row (flushed by <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task RecordAsync(int legacyAanvraagId, Guid registrationApplicationId, DateTimeOffset takenOverAt, CancellationToken ct);
|
||||
|
||||
/// <summary>Increments `domain_writes_since` for a domain write against an adopted aggregate.</summary>
|
||||
Task IncrementDomainWritesAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages removal of the ownership row (flushed by <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task RemoveAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>Owned-side persistence port for the <see cref="RegistrationApplication"/> aggregate.</summary>
|
||||
public interface IRegistrationApplicationRepository
|
||||
{
|
||||
Task<RegistrationApplication?> GetAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages a brand-new aggregate for insertion (flushed on the next <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task AddAsync(RegistrationApplication application, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages an aggregate for deletion (flushed on the next <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task RemoveAsync(RegistrationApplication application, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// Commits everything staged through <see cref="IRegistrationApplicationRepository"/>
|
||||
/// and <see cref="IOwnershipRegistry"/> in one local transaction. In the
|
||||
/// Persistence adapter both ports are backed by the same scoped DbContext, so
|
||||
/// a single SaveChangesAsync call is genuinely atomic across them.
|
||||
/// </summary>
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>
|
||||
/// Plain read-model carrier for an address - deliberately NOT the
|
||||
/// New.Domain.ValueObjects.Address value object. Read models cross into
|
||||
/// New.Api for JSON shaping and must stay decoupled from domain invariants
|
||||
/// (e.g. a query result can legitimately be assembled straight from a
|
||||
/// legacy/case-framework response before any domain validation happens).
|
||||
/// </summary>
|
||||
public sealed record AddressData(string Street, string Number, string PostalCode, string City);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>Read-model projection of a recorded assessment, for display purposes.</summary>
|
||||
public sealed record AssessmentData(
|
||||
string Outcome,
|
||||
string Motivation,
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
string? RejectionCategory,
|
||||
DateOnly DecidedOn);
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>
|
||||
/// Full case detail projection, shaped the same way regardless of which
|
||||
/// source it came from - New.Api layers the `actions`/`seams` blocks on top
|
||||
/// based on <see cref="Origin"/>.
|
||||
/// </summary>
|
||||
public sealed record CaseDetail(
|
||||
WorklistOrigin Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
AddressData? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel,
|
||||
string DiplomaCode,
|
||||
string DiplomaCountryOfIssue,
|
||||
DateOnly DiplomaIssuedOn,
|
||||
DateOnly ReceivedOn,
|
||||
AssessmentData? Assessment,
|
||||
string? ProcessStatus,
|
||||
Guid? CaseFrameworkCaseId,
|
||||
bool Migrated,
|
||||
/// <summary>
|
||||
/// UTC instant of the last legacy mutation, if known. Legacy's own
|
||||
/// `mutDat` is a local Europe/Amsterdam timestamp with no offset - the
|
||||
/// legacy mapper converts it explicitly via that time zone rather than
|
||||
/// assuming UTC (which would silently shift it by 1-2 hours depending on
|
||||
/// DST). Null for owned/native cases with no legacy mutation history.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastModifiedAt = null);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the merged worklist (GET /api/worklist). New.Api fetches these
|
||||
/// from both sources in full and merges/sorts/pages them in memory - a known
|
||||
/// shortcut for this demo's seed volumes (12 legacy + 5 owned rows); a
|
||||
/// production version would need keyset pagination per source or a
|
||||
/// materialized index instead.
|
||||
/// </summary>
|
||||
public sealed record WorklistItem(
|
||||
WorklistOrigin Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
DateOnly ReceivedOn,
|
||||
string Bucket,
|
||||
string? AssessmentOutcome,
|
||||
string? ProcessStatus,
|
||||
DateTimeOffset? LastModifiedAt = null,
|
||||
/// <summary>
|
||||
/// True for a legacy row already taken into ownership. New.Api's worklist
|
||||
/// merge excludes such rows from the legacy list (the owned counterpart
|
||||
/// already represents them) - see the merge comment in New.Api.
|
||||
/// </summary>
|
||||
bool Migrated = false);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>Which of the two sources a worklist item or case detail came from.</summary>
|
||||
public enum WorklistOrigin
|
||||
{
|
||||
Legacy,
|
||||
Owned,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
/// <summary>
|
||||
/// The 9-field "edit applicant details" command, shared verbatim by the
|
||||
/// legacy write-through seam (PUT /api/worklist/legacy/{id}/details) and the
|
||||
/// owned edit path (PUT /api/worklist/owned/{id}/details).
|
||||
/// </summary>
|
||||
public sealed record ApplicantDetailsCommand(
|
||||
string Surname,
|
||||
string Initials,
|
||||
AddressData? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel);
|
||||
@@ -0,0 +1,60 @@
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates PUT /api/worklist/owned/{id}/details - the owned-side
|
||||
/// counterpart to the legacy write-through seam. Unlike the write-through
|
||||
/// translator, this path re-validates through the domain's real value
|
||||
/// objects (there is no external "legacy is the sole authority" constraint
|
||||
/// here - this IS the authority once a case is owned).
|
||||
/// </summary>
|
||||
public sealed class UpdateOwnedApplicantDetailsHandler(
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork,
|
||||
IOwnershipRegistry registry)
|
||||
{
|
||||
public async Task<UpdateOwnedApplicantDetailsResult> HandleAsync(
|
||||
Guid registrationApplicationId,
|
||||
ApplicantDetailsCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var application = await repository.GetAsync(registrationApplicationId, ct);
|
||||
if (application is null)
|
||||
{
|
||||
return UpdateOwnedApplicantDetailsResult.NotFound;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var applicant = new PersonName(command.Surname, command.Initials);
|
||||
var address = command.Address is { } a
|
||||
? new Address(a.Street, a.Number, a.PostalCode, a.City)
|
||||
: null;
|
||||
var channel = ParseChannel(command.PreferredChannel);
|
||||
var contactDetails = new ContactDetails(command.Email, command.Phone, channel);
|
||||
|
||||
application.UpdateApplicantDetails(applicant, address, contactDetails);
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return UpdateOwnedApplicantDetailsResult.InvariantViolation(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
await registry.IncrementDomainWritesAsync(registrationApplicationId, ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
return UpdateOwnedApplicantDetailsResult.Success;
|
||||
}
|
||||
|
||||
private static CorrespondenceChannel ParseChannel(string preferredChannel) => preferredChannel switch
|
||||
{
|
||||
"Post" => CorrespondenceChannel.Post,
|
||||
"Email" => CorrespondenceChannel.Email,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"ContactDetails.UnrecognizedChannel",
|
||||
$"'{preferredChannel}' is not a recognized preferred channel (expected 'Post' or 'Email')."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
public enum UpdateOwnedApplicantDetailsResultKind
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
InvariantViolation,
|
||||
}
|
||||
|
||||
public sealed record UpdateOwnedApplicantDetailsResult(
|
||||
UpdateOwnedApplicantDetailsResultKind Kind,
|
||||
string? Invariant = null,
|
||||
string? Message = null)
|
||||
{
|
||||
public static readonly UpdateOwnedApplicantDetailsResult Success = new(UpdateOwnedApplicantDetailsResultKind.Success);
|
||||
public static readonly UpdateOwnedApplicantDetailsResult NotFound = new(UpdateOwnedApplicantDetailsResultKind.NotFound);
|
||||
|
||||
public static UpdateOwnedApplicantDetailsResult InvariantViolation(string invariant, string message) =>
|
||||
new(UpdateOwnedApplicantDetailsResultKind.InvariantViolation, invariant, message);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
/// <summary>
|
||||
/// A single portal-shaped field error, after the write-through translator has
|
||||
/// mapped a legacy `veld`/`code`/`melding` triple. <see cref="Detail"/> only
|
||||
/// carries the raw legacy message, and only for codes the translator did not
|
||||
/// recognize - see the translator's class-level comment on why it must never
|
||||
/// invent business meaning for a code it doesn't know.
|
||||
/// </summary>
|
||||
public sealed record PortalFieldError(string Field, string Message, string? Detail = null);
|
||||
|
||||
public enum WriteThroughOutcomeKind
|
||||
{
|
||||
Success,
|
||||
ValidationFailed,
|
||||
Conflict,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
public sealed record WriteThroughOutcome(WriteThroughOutcomeKind Kind, IReadOnlyList<PortalFieldError>? Errors = null)
|
||||
{
|
||||
public static readonly WriteThroughOutcome Success = new(WriteThroughOutcomeKind.Success);
|
||||
public static readonly WriteThroughOutcome Conflict = new(WriteThroughOutcomeKind.Conflict);
|
||||
public static readonly WriteThroughOutcome NotFound = new(WriteThroughOutcomeKind.NotFound);
|
||||
|
||||
public static WriteThroughOutcome ValidationFailed(IReadOnlyList<PortalFieldError> errors) =>
|
||||
new(WriteThroughOutcomeKind.ValidationFailed, errors);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace New.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown whenever a domain invariant is violated - by native creation of a
|
||||
/// <see cref="RegistrationApplication"/>, or by the legacy mapper feeding data
|
||||
/// through the same validating constructors/factories during adoption.
|
||||
///
|
||||
/// <see cref="Invariant"/> is a short, stable, machine-friendly code (e.g.
|
||||
/// "Bsn.ElevenProof", "Assessment.MotivationTooShort") that callers such as
|
||||
/// New.Api can surface directly in a 422 response body without needing to
|
||||
/// parse the human-readable <see cref="Exception.Message"/>.
|
||||
/// </summary>
|
||||
public sealed class DomainInvariantViolationException : Exception
|
||||
{
|
||||
public string Invariant { get; }
|
||||
|
||||
public DomainInvariantViolationException(string invariant, string message)
|
||||
: base(message)
|
||||
{
|
||||
Invariant = invariant;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Domain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
No package or project references on purpose: New.Domain is the innermost
|
||||
layer of the DDD-flavored design in this solution. It must never depend
|
||||
on EF Core, HttpClient, ASP.NET Core, or DTOs from the legacy system or
|
||||
the case-framework - those are all infrastructure concerns kept out by
|
||||
the project-reference graph (see Architecture.Tests for the enforced rules).
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,186 @@
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate root for a registration application ("aanvraag" in the legacy
|
||||
/// system). Has no base class, no `ICaseEntity` interface, and no property of
|
||||
/// a type from the case-framework or legacy DTOs - see Architecture.Tests for
|
||||
/// the enforced boundary. All invariants are enforced here, in the
|
||||
/// constructor/factory methods and the mutation methods below, so the same
|
||||
/// rules apply whether an instance is created natively (owned path/seed data)
|
||||
/// or reconstructed from legacy data during adoption
|
||||
/// (New.Infrastructure.Legacy.LegacyAanvraagMapper calls straight into these
|
||||
/// same methods and lets domain exceptions propagate as mapping failures).
|
||||
/// </summary>
|
||||
public sealed class RegistrationApplication
|
||||
{
|
||||
public Guid RegistrationApplicationId { get; }
|
||||
public Bsn Bsn { get; private set; }
|
||||
public PersonName Applicant { get; private set; }
|
||||
public Address? CorrespondenceAddress { get; private set; }
|
||||
public ContactDetails ContactDetails { get; private set; }
|
||||
public DiplomaEvidence DiplomaEvidence { get; private set; }
|
||||
public Assessment? Assessment { get; private set; }
|
||||
public DateOnly ReceivedOn { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation to the case-framework case. Nullable here - a deliberate,
|
||||
/// documented deviation from the aggregate's conceptual model, where a
|
||||
/// case reference is always expected: during adoption (take-ownership),
|
||||
/// the mapping to a valid <see cref="RegistrationApplication"/> (step 3)
|
||||
/// must succeed and fail fast BEFORE the case-framework case is created
|
||||
/// (step 4 - see the take-ownership handler), so there is a real,
|
||||
/// unavoidable moment where a fully-valid aggregate exists with no case
|
||||
/// reference yet. <see cref="AttachCaseReference"/> fills it in
|
||||
/// immediately after, before anything is persisted.
|
||||
/// </summary>
|
||||
public CaseReference? Case { get; private set; }
|
||||
|
||||
private RegistrationApplication(
|
||||
Guid registrationApplicationId,
|
||||
Bsn bsn,
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails,
|
||||
DiplomaEvidence diplomaEvidence,
|
||||
DateOnly receivedOn,
|
||||
CaseReference? caseReference,
|
||||
Assessment? assessment)
|
||||
{
|
||||
RegistrationApplicationId = registrationApplicationId;
|
||||
Bsn = bsn;
|
||||
Applicant = applicant;
|
||||
CorrespondenceAddress = correspondenceAddress;
|
||||
ContactDetails = contactDetails;
|
||||
DiplomaEvidence = diplomaEvidence;
|
||||
ReceivedOn = receivedOn;
|
||||
Case = caseReference;
|
||||
Assessment = assessment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new application. Used both for genuinely native creation and
|
||||
/// by the legacy mapper during adoption (with <paramref name="caseReference"/>
|
||||
/// left null, attached afterwards via <see cref="AttachCaseReference"/>).
|
||||
/// </summary>
|
||||
public static RegistrationApplication Create(
|
||||
Guid registrationApplicationId,
|
||||
Bsn bsn,
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails,
|
||||
DiplomaEvidence diplomaEvidence,
|
||||
DateOnly receivedOn,
|
||||
CaseReference? caseReference = null)
|
||||
{
|
||||
if (registrationApplicationId == Guid.Empty)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"RegistrationApplication.IdRequired",
|
||||
"A registration application must have a non-empty id.");
|
||||
}
|
||||
|
||||
return new RegistrationApplication(
|
||||
registrationApplicationId,
|
||||
bsn,
|
||||
applicant,
|
||||
correspondenceAddress,
|
||||
contactDetails,
|
||||
diplomaEvidence,
|
||||
receivedOn,
|
||||
caseReference,
|
||||
assessment: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs an application with a pre-existing assessment (used when
|
||||
/// rehydrating from storage, or when adopting an already-assessed legacy
|
||||
/// case). Goes through the same <see cref="Assessment.Create"/> validation.
|
||||
/// </summary>
|
||||
public static RegistrationApplication CreateWithAssessment(
|
||||
Guid registrationApplicationId,
|
||||
Bsn bsn,
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails,
|
||||
DiplomaEvidence diplomaEvidence,
|
||||
DateOnly receivedOn,
|
||||
Assessment assessment,
|
||||
CaseReference? caseReference = null)
|
||||
{
|
||||
var application = Create(
|
||||
registrationApplicationId,
|
||||
bsn,
|
||||
applicant,
|
||||
correspondenceAddress,
|
||||
contactDetails,
|
||||
diplomaEvidence,
|
||||
receivedOn,
|
||||
caseReference);
|
||||
|
||||
application.Assessment = assessment;
|
||||
return application;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches this aggregate to its case-framework case. Callable exactly
|
||||
/// once - see the class remarks on <see cref="Case"/> for why this exists
|
||||
/// as a separate step instead of a constructor parameter.
|
||||
/// </summary>
|
||||
public void AttachCaseReference(CaseReference caseReference)
|
||||
{
|
||||
if (Case is not null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"RegistrationApplication.CaseAlreadyAttached",
|
||||
"This application is already correlated to a case-framework case.");
|
||||
}
|
||||
|
||||
Case = caseReference;
|
||||
}
|
||||
|
||||
/// <summary>Updates the case-framework's own process status mirror.</summary>
|
||||
public void UpdateProcessStatus(string? processStatus)
|
||||
{
|
||||
if (Case is null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"RegistrationApplication.CaseNotAttached",
|
||||
"Cannot update process status before a case reference is attached.");
|
||||
}
|
||||
|
||||
Case = Case.WithProcessStatus(processStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits the applicant-facing details through the owned path (i.e. not
|
||||
/// the legacy write-through seam). Re-validates every invariant exactly
|
||||
/// like construction does, since these are the same value objects.
|
||||
/// </summary>
|
||||
public void UpdateApplicantDetails(
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails)
|
||||
{
|
||||
Applicant = applicant;
|
||||
CorrespondenceAddress = correspondenceAddress;
|
||||
ContactDetails = contactDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the outcome of an assessment. All validation lives in
|
||||
/// <see cref="Assessment.Create"/> - this method's job is purely to apply
|
||||
/// the result to the aggregate.
|
||||
/// </summary>
|
||||
public void RecordAssessment(
|
||||
AssessmentOutcome outcome,
|
||||
string motivation,
|
||||
IReadOnlyList<string>? verifiedItems,
|
||||
string? exceptionReason,
|
||||
string? rejectionCategory,
|
||||
DateOnly decidedOn)
|
||||
{
|
||||
Assessment = Assessment.Create(outcome, motivation, verifiedItems, exceptionReason, rejectionCategory, decidedOn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// A correspondence address. All four parts are required by this constructor
|
||||
/// on purpose: the "all four or nothing" rule (a partial address is no
|
||||
/// address) is enforced by never letting callers construct a partial
|
||||
/// instance, not by making the parts nullable here. Callers that only have
|
||||
/// partial data (e.g. the legacy mapper facing four independently-nullable
|
||||
/// columns) decide whether to construct an <see cref="Address"/> at all -
|
||||
/// see <see cref="RegistrationApplication.CorrespondenceAddress"/>, which is
|
||||
/// itself nullable for exactly this reason.
|
||||
/// </summary>
|
||||
public sealed record Address
|
||||
{
|
||||
public string Street { get; }
|
||||
public string Number { get; }
|
||||
public string PostalCode { get; }
|
||||
public string City { get; }
|
||||
|
||||
public Address(string street, string number, string postalCode, string city)
|
||||
{
|
||||
RequireNonBlank(street, "street");
|
||||
RequireNonBlank(number, "number");
|
||||
RequireNonBlank(postalCode, "postalCode");
|
||||
RequireNonBlank(city, "city");
|
||||
|
||||
Street = street;
|
||||
Number = number;
|
||||
PostalCode = postalCode;
|
||||
City = city;
|
||||
}
|
||||
|
||||
private static void RequireNonBlank(string value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Address.AllPartsRequired",
|
||||
$"Address.{fieldName} is required whenever an address is present (partial address = no address).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The decision on an application. Named <c>AssessmentOutcome</c> - never the
|
||||
/// bare word "Status" - to keep it distinct from the case-framework's own
|
||||
/// <c>ProcessStatus</c> and from the legacy `stat_cd` / `beoordRes` codes.
|
||||
/// Also stands in for what the case-framework calls a "Decision" document.
|
||||
/// </summary>
|
||||
public enum AssessmentOutcome
|
||||
{
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recorded assessment of a <see cref="RegistrationApplication"/>. Only ever
|
||||
/// constructed through <see cref="Create"/>, which enforces every invariant
|
||||
/// so the same validation applies whether the assessment is entered natively
|
||||
/// through the owned path or reconstructed from legacy data during adoption.
|
||||
/// </summary>
|
||||
public sealed record Assessment
|
||||
{
|
||||
private const int DefaultMinimumMotivationLength = 20;
|
||||
private const int OtherCategoryMinimumMotivationLength = 50;
|
||||
|
||||
public AssessmentOutcome Outcome { get; }
|
||||
public string Motivation { get; }
|
||||
public IReadOnlyList<string> VerifiedItems { get; }
|
||||
public string? ExceptionReason { get; }
|
||||
public string? RejectionCategory { get; }
|
||||
public DateOnly DecidedOn { get; }
|
||||
|
||||
private Assessment(
|
||||
AssessmentOutcome outcome,
|
||||
string motivation,
|
||||
IReadOnlyList<string> verifiedItems,
|
||||
string? exceptionReason,
|
||||
string? rejectionCategory,
|
||||
DateOnly decidedOn)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Motivation = motivation;
|
||||
VerifiedItems = verifiedItems;
|
||||
ExceptionReason = exceptionReason;
|
||||
RejectionCategory = rejectionCategory;
|
||||
DecidedOn = decidedOn;
|
||||
}
|
||||
|
||||
public static Assessment Create(
|
||||
AssessmentOutcome outcome,
|
||||
string motivation,
|
||||
IReadOnlyList<string>? verifiedItems,
|
||||
string? exceptionReason,
|
||||
string? rejectionCategory,
|
||||
DateOnly decidedOn)
|
||||
{
|
||||
var items = verifiedItems ?? Array.Empty<string>();
|
||||
|
||||
// Recording an assessment requires either every diploma evidence item
|
||||
// to have been verified, or a recorded reason why verification was
|
||||
// skipped - never neither.
|
||||
if (items.Count == 0 && string.IsNullOrWhiteSpace(exceptionReason))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Assessment.VerificationRequired",
|
||||
"Recording an assessment requires either at least one verified item or a recorded exception reason.");
|
||||
}
|
||||
|
||||
// rejectionCategory only makes sense - and is only carried - alongside
|
||||
// a Rejected outcome.
|
||||
var normalizedRejectionCategory = outcome == AssessmentOutcome.Rejected
|
||||
? rejectionCategory
|
||||
: null;
|
||||
|
||||
if (outcome == AssessmentOutcome.Rejected && string.IsNullOrWhiteSpace(normalizedRejectionCategory))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Assessment.RejectionCategoryRequired",
|
||||
"A rejection category is required when the outcome is Rejected.");
|
||||
}
|
||||
|
||||
var minimumLength = IsOtherCategory(normalizedRejectionCategory)
|
||||
? OtherCategoryMinimumMotivationLength
|
||||
: DefaultMinimumMotivationLength;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(motivation) || motivation.Trim().Length < minimumLength)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Assessment.MotivationTooShort",
|
||||
$"The motivation must be at least {minimumLength} characters long" +
|
||||
(IsOtherCategory(normalizedRejectionCategory) ? " when the rejection category is 'Other'." : "."));
|
||||
}
|
||||
|
||||
return new Assessment(outcome, motivation, items, exceptionReason, normalizedRejectionCategory, decidedOn);
|
||||
}
|
||||
|
||||
private static bool IsOtherCategory(string? rejectionCategory) =>
|
||||
rejectionCategory is not null &&
|
||||
(string.Equals(rejectionCategory, "Other", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(rejectionCategory, "anders", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// A Dutch "burgerservicenummer" - always exactly 9 digits, validated with the
|
||||
/// eleven-proof (elfproef) checksum.
|
||||
///
|
||||
/// This value object deliberately does NOT trim or otherwise massage its
|
||||
/// input. The legacy source stores BSNs as a space-padded CHAR(9), and it is
|
||||
/// the legacy mapper's job (New.Infrastructure.Legacy.LegacyAanvraagMapper)
|
||||
/// to trim before handing the raw value to this constructor - if it forgets,
|
||||
/// this constructor throws, which is the point: silently accepting padded
|
||||
/// input here would hide that mapping bug instead of surfacing it.
|
||||
/// </summary>
|
||||
public sealed record Bsn
|
||||
{
|
||||
public string Value { get; }
|
||||
|
||||
public Bsn(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length != 9 || !value.All(char.IsDigit))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Bsn.Format",
|
||||
$"A BSN must be exactly 9 digits. Got '{value}'.");
|
||||
}
|
||||
|
||||
if (!PassesElevenProof(value))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Bsn.ElevenProof",
|
||||
$"'{value}' does not pass the eleven-proof (elfproef) checksum.");
|
||||
}
|
||||
|
||||
// All-zero digits trivially satisfy the eleven-proof formula (every
|
||||
// weighted term is zero) but "000000000" has never been an issued
|
||||
// BSN - real BSN validation excludes it explicitly, not just via the
|
||||
// checksum.
|
||||
if (value == "000000000")
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Bsn.ElevenProof",
|
||||
"'000000000' is not a valid BSN.");
|
||||
}
|
||||
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (9*d1 + 8*d2 + 7*d3 + 6*d4 + 5*d5 + 4*d6 + 3*d7 + 2*d8 - 1*d9) % 11 == 0
|
||||
/// </summary>
|
||||
private static bool PassesElevenProof(string digits)
|
||||
{
|
||||
var sum = 0;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var weight = 9 - i;
|
||||
sum += weight * (digits[i] - '0');
|
||||
}
|
||||
|
||||
sum -= digits[8] - '0';
|
||||
|
||||
return sum % 11 == 0;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Pure correlation to the case-framework's own case - deliberately NOT
|
||||
/// inheritance and NOT a base class. <see cref="RegistrationApplication"/>
|
||||
/// has-a <see cref="CaseReference"/>, it is not-a case-framework case.
|
||||
///
|
||||
/// <see cref="ProcessStatus"/> mirrors the case-framework's own process state
|
||||
/// (its vocabulary, e.g. "InBehandeling"/"Afgesloten") purely for display -
|
||||
/// it is intentionally never named just "Status", to keep it distinct from
|
||||
/// this application's own <see cref="AssessmentOutcome"/> decision.
|
||||
/// </summary>
|
||||
public sealed record CaseReference
|
||||
{
|
||||
public Guid FrameworkCaseId { get; }
|
||||
public string ExternalReference { get; }
|
||||
public string? ProcessStatus { get; }
|
||||
|
||||
public CaseReference(Guid frameworkCaseId, string externalReference, string? processStatus)
|
||||
{
|
||||
if (frameworkCaseId == Guid.Empty)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"CaseReference.FrameworkCaseIdRequired",
|
||||
"A case reference must point at a real case-framework case.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalReference))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"CaseReference.ExternalReferenceRequired",
|
||||
"A case reference must carry the external reference it was correlated by.");
|
||||
}
|
||||
|
||||
FrameworkCaseId = frameworkCaseId;
|
||||
ExternalReference = externalReference;
|
||||
ProcessStatus = processStatus;
|
||||
}
|
||||
|
||||
public CaseReference WithProcessStatus(string? processStatus) =>
|
||||
new(FrameworkCaseId, ExternalReference, processStatus);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>How the applicant prefers to be contacted.</summary>
|
||||
public enum CorrespondenceChannel
|
||||
{
|
||||
Post,
|
||||
Email,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email/phone plus the applicant's preferred channel. The one real invariant:
|
||||
/// choosing <see cref="CorrespondenceChannel.Email"/> requires a non-empty,
|
||||
/// well-formed email address - you can't ask to be emailed with no email on file.
|
||||
/// </summary>
|
||||
public sealed record ContactDetails
|
||||
{
|
||||
private static readonly Regex SimpleEmailPattern =
|
||||
new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);
|
||||
|
||||
public string? Email { get; }
|
||||
public string? Phone { get; }
|
||||
public CorrespondenceChannel PreferredChannel { get; }
|
||||
|
||||
public ContactDetails(string? email, string? phone, CorrespondenceChannel preferredChannel)
|
||||
{
|
||||
if (preferredChannel == CorrespondenceChannel.Email && !IsWellFormedEmail(email))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"ContactDetails.EmailRequiredForEmailChannel",
|
||||
"Preferring email as the correspondence channel requires a non-empty, well-formed email address.");
|
||||
}
|
||||
|
||||
Email = email;
|
||||
Phone = phone;
|
||||
PreferredChannel = preferredChannel;
|
||||
}
|
||||
|
||||
private static bool IsWellFormedEmail(string? email) =>
|
||||
!string.IsNullOrWhiteSpace(email) && SimpleEmailPattern.IsMatch(email);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>Evidence of a foreign diploma submitted in support of the application.</summary>
|
||||
public sealed record DiplomaEvidence
|
||||
{
|
||||
public string Code { get; }
|
||||
public string CountryOfIssue { get; }
|
||||
public DateOnly IssuedOn { get; }
|
||||
|
||||
public DiplomaEvidence(string code, string countryOfIssue, DateOnly issuedOn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"DiplomaEvidence.CodeRequired",
|
||||
"A diploma evidence code is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(countryOfIssue))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"DiplomaEvidence.CountryOfIssueRequired",
|
||||
"A country of issue is required.");
|
||||
}
|
||||
|
||||
Code = code;
|
||||
CountryOfIssue = countryOfIssue;
|
||||
IssuedOn = issuedOn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The applicant's name. Deliberately called <c>PersonName</c>/<c>Applicant</c>
|
||||
/// rather than the case-framework's "Participant" - see the false-cognates
|
||||
/// table in the migration design notes.
|
||||
/// </summary>
|
||||
public sealed record PersonName
|
||||
{
|
||||
public string Surname { get; }
|
||||
public string Initials { get; }
|
||||
|
||||
public PersonName(string surname, string initials)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(surname))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"PersonName.SurnameRequired",
|
||||
"A surname is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(initials))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"PersonName.InitialsRequired",
|
||||
"Initials are required.");
|
||||
}
|
||||
|
||||
Surname = surname;
|
||||
Initials = initials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using New.Infrastructure.CaseFramework.Dtos;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework;
|
||||
|
||||
/// <summary>Thin wrapper around the case-framework HttpClient - the one place that knows its exact routes.</summary>
|
||||
internal sealed class CaseFrameworkClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<CreateCaseResponse> CreateCaseAsync(CreateCaseRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsJsonAsync("/cases", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<CreateCaseResponse>(ct))!;
|
||||
}
|
||||
|
||||
public async Task<CaseResponse?> GetCaseAsync(Guid caseId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.GetAsync($"/cases/{caseId}", ct);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<CaseResponse>(ct);
|
||||
}
|
||||
|
||||
public async Task<CreateTaskResponse> CreateTaskAsync(Guid caseId, CreateTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsJsonAsync($"/cases/{caseId}/tasks", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<CreateTaskResponse>(ct))!;
|
||||
}
|
||||
|
||||
public async Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsync($"/cases/{caseId}/tasks/{taskId}/complete", content: null, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the case closed (204), false if the framework returned 409 (an open task).</summary>
|
||||
public async Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsync($"/cases/{caseId}/closure-request", content: null, ct);
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.CaseFramework.Dtos;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework;
|
||||
|
||||
/// <summary>Seam D: implements <see cref="ICaseFrameworkGateway"/> against the case-framework's own contract.</summary>
|
||||
public sealed class CaseFrameworkGateway : ICaseFrameworkGateway
|
||||
{
|
||||
private readonly CaseFrameworkClient _client;
|
||||
|
||||
// Internal constructor parameter type (CaseFrameworkClient is internal -
|
||||
// its API is shaped by case-framework DTOs). Registered via an explicit
|
||||
// factory in ServiceCollectionExtensions; see that file's remarks.
|
||||
internal CaseFrameworkGateway(CaseFrameworkClient client) => _client = client;
|
||||
|
||||
public async Task<CaseCreated> CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList<string> participants, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.CreateCaseAsync(
|
||||
new CreateCaseRequest(caseTypeCode, externalReference, participants.ToList()), ct);
|
||||
return new CaseCreated(response.Id, response.ProcessStatus);
|
||||
}
|
||||
|
||||
public async Task<string?> GetProcessStatusAsync(Guid caseId, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.GetCaseAsync(caseId, ct);
|
||||
return response?.ProcessStatus;
|
||||
}
|
||||
|
||||
public async Task<TaskCreated> CreateTaskAsync(Guid caseId, string code, string description, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.CreateTaskAsync(caseId, new CreateTaskRequest(code, description), ct);
|
||||
return new TaskCreated(response.TaskId, response.Open);
|
||||
}
|
||||
|
||||
public Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct) =>
|
||||
_client.CompleteTaskAsync(caseId, taskId, ct);
|
||||
|
||||
public Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct) =>
|
||||
_client.RequestClosureAsync(caseId, ct);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Case-framework's own wire shapes, exactly as documented in the migration
|
||||
/// design notes. Internal to this project - nothing outside
|
||||
/// New.Infrastructure.CaseFramework may reference these types
|
||||
/// (Architecture.Tests rule 4).
|
||||
/// </summary>
|
||||
internal sealed record CreateCaseRequest(
|
||||
[property: JsonPropertyName("caseTypeCode")] string CaseTypeCode,
|
||||
[property: JsonPropertyName("externalReference")] string ExternalReference,
|
||||
[property: JsonPropertyName("participants")] List<string> Participants);
|
||||
|
||||
internal sealed record CreateCaseResponse(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("processStatus")] string? ProcessStatus);
|
||||
|
||||
internal sealed record CaseResponse(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("caseTypeCode")] string CaseTypeCode,
|
||||
[property: JsonPropertyName("externalReference")] string ExternalReference,
|
||||
[property: JsonPropertyName("processStatus")] string? ProcessStatus,
|
||||
[property: JsonPropertyName("participants")] List<string> Participants);
|
||||
|
||||
internal sealed record TimelineEntryResponse(
|
||||
[property: JsonPropertyName("at")] DateTimeOffset At,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("description")] string Description);
|
||||
|
||||
internal sealed record TimelineResponse(
|
||||
[property: JsonPropertyName("entries")] List<TimelineEntryResponse> Entries);
|
||||
|
||||
internal sealed record CreateTaskRequest(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("description")] string Description);
|
||||
|
||||
internal sealed record CreateTaskResponse(
|
||||
[property: JsonPropertyName("taskId")] Guid TaskId,
|
||||
[property: JsonPropertyName("open")] bool Open);
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Infrastructure.CaseFramework</RootNamespace>
|
||||
<!--
|
||||
Case-framework DTOs in this project are `internal` on purpose
|
||||
(Architecture.Tests rule 4). See New.Infrastructure.Legacy.csproj for
|
||||
why no InternalsVisibleTo is needed for the architecture tests to see them.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace New.Infrastructure.CaseFramework.Options;
|
||||
|
||||
public sealed class CaseFrameworkOptions
|
||||
{
|
||||
public const string SectionName = "Services:CaseFramework";
|
||||
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.CaseFramework.Options;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for this project - see
|
||||
/// New.Infrastructure.Persistence.ServiceCollectionExtensions for the
|
||||
/// rationale (Program.cs never names concrete infra types directly), and
|
||||
/// New.Infrastructure.Legacy.ServiceCollectionExtensions for why the
|
||||
/// CaseFrameworkClient-dependent registration below uses an explicit factory
|
||||
/// delegate rather than relying on reflection-based auto-construction.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string HttpClientName = "CaseFramework";
|
||||
|
||||
public static IServiceCollection AddCaseFrameworkInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<CaseFrameworkOptions>(configuration.GetSection(CaseFrameworkOptions.SectionName));
|
||||
|
||||
services.AddHttpClient(HttpClientName, (sp, http) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<CaseFrameworkOptions>>().Value;
|
||||
http.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new CaseFrameworkClient(sp.GetRequiredService<IHttpClientFactory>().CreateClient(HttpClientName)));
|
||||
|
||||
services.AddScoped<ICaseFrameworkGateway>(sp =>
|
||||
new CaseFrameworkGateway(sp.GetRequiredService<CaseFrameworkClient>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's `mutDat` is a local Europe/Amsterdam DATETIME2 with no offset -
|
||||
/// converting it to a UTC-backed DateTimeOffset requires explicitly applying
|
||||
/// this time zone (including DST), never assuming it's already UTC (which
|
||||
/// would silently shift every audit timestamp by 1-2 hours).
|
||||
/// </summary>
|
||||
internal static class AmsterdamClock
|
||||
{
|
||||
private static readonly TimeZoneInfo Amsterdam = TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam");
|
||||
|
||||
public static DateTimeOffset ToUtcOffset(DateTime localUnspecified)
|
||||
{
|
||||
var unspecified = DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified);
|
||||
var utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, Amsterdam);
|
||||
return new DateTimeOffset(utc, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's own row shape, exactly as documented in the migration design
|
||||
/// notes, plus an `id` field: the quoted contract doesn't list it explicitly,
|
||||
/// but GET /api/aanvragen/{id} is id-addressed, so the row necessarily
|
||||
/// carries its own id. Internal to this project - nothing outside
|
||||
/// New.Infrastructure.Legacy may reference this type (Architecture.Tests rule 3).
|
||||
/// </summary>
|
||||
internal sealed record LegacyAanvraagDto(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("bsn")] string Bsn,
|
||||
[property: JsonPropertyName("naam")] string Naam,
|
||||
[property: JsonPropertyName("voorl")] string Voorl,
|
||||
[property: JsonPropertyName("adresStr")] string? AdresStr,
|
||||
[property: JsonPropertyName("adresNr")] string? AdresNr,
|
||||
[property: JsonPropertyName("adresPc")] string? AdresPc,
|
||||
[property: JsonPropertyName("adresPl")] string? AdresPl,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("telnr")] string? Telnr,
|
||||
[property: JsonPropertyName("corrKanaal")] string CorrKanaal,
|
||||
[property: JsonPropertyName("statCd")] string StatCd,
|
||||
[property: JsonPropertyName("diplCd")] string DiplCd,
|
||||
[property: JsonPropertyName("diplLand")] string DiplLand,
|
||||
[property: JsonPropertyName("diplDat")] DateOnly DiplDat,
|
||||
[property: JsonPropertyName("datOntv")] DateOnly DatOntv,
|
||||
[property: JsonPropertyName("datBeoord")] DateOnly? DatBeoord,
|
||||
[property: JsonPropertyName("beoordRes")] string? BeoordRes,
|
||||
[property: JsonPropertyName("beoordMotiv")] string? BeoordMotiv,
|
||||
[property: JsonPropertyName("migrated")] bool Migrated,
|
||||
[property: JsonPropertyName("mutDat")] DateTime MutDat,
|
||||
[property: JsonPropertyName("mutUser")] string? MutUser);
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
/// <summary>Seam B request body - legacy was told to accept exactly this portal-facing shape.</summary>
|
||||
internal sealed record LegacyDetailsWriteRequest(
|
||||
[property: JsonPropertyName("surname")] string Surname,
|
||||
[property: JsonPropertyName("initials")] string Initials,
|
||||
[property: JsonPropertyName("address")] LegacyAddressWriteRequest? Address,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("phone")] string? Phone,
|
||||
[property: JsonPropertyName("preferredChannel")] string PreferredChannel);
|
||||
|
||||
internal sealed record LegacyAddressWriteRequest(
|
||||
[property: JsonPropertyName("street")] string Street,
|
||||
[property: JsonPropertyName("number")] string Number,
|
||||
[property: JsonPropertyName("postalCode")] string PostalCode,
|
||||
[property: JsonPropertyName("city")] string City);
|
||||
|
||||
internal sealed record LegacyValidationErrorResponse(
|
||||
[property: JsonPropertyName("errors")] List<LegacyValidationError> Errors);
|
||||
|
||||
internal sealed record LegacyValidationError(
|
||||
[property: JsonPropertyName("veld")] string Veld,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("melding")] string Melding);
|
||||
|
||||
internal sealed record MigratieVlagRequest(
|
||||
[property: JsonPropertyName("migrated")] bool Migrated);
|
||||
@@ -0,0 +1,132 @@
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a legacy row to a <see cref="RegistrationApplication"/>. Every
|
||||
/// invariant is enforced by calling straight into the domain's own
|
||||
/// validating constructors/factories - none of this mapping logic lives in
|
||||
/// New.Domain, and a domain exception thrown here is exactly the signal the
|
||||
/// take-ownership handler needs to fail adoption with a named invariant.
|
||||
///
|
||||
/// Each numbered comment below is a deliberate defect trap this mapper must
|
||||
/// not fall into.
|
||||
/// </summary>
|
||||
internal static class LegacyAanvraagMapper
|
||||
{
|
||||
public static RegistrationApplication ToDomain(LegacyAanvraagDto dto)
|
||||
{
|
||||
// 1) bsn is a space-padded CHAR(9) in the source. The padding is
|
||||
// invisible in JSON output but breaks the eleven-proof check if not
|
||||
// trimmed - Bsn's constructor deliberately does NOT trim, so this
|
||||
// Trim() is load-bearing, not defensive fluff.
|
||||
var bsn = new Bsn(dto.Bsn.Trim());
|
||||
|
||||
var applicant = new PersonName(dto.Naam, dto.Voorl);
|
||||
|
||||
// 2) statCd must map to a named enum; an unrecognized code throws
|
||||
// rather than silently defaulting. Not stored on the domain aggregate
|
||||
// (it has no business meaning there - see New.Infrastructure.Legacy.LegacyAanvraagStatus)
|
||||
// but still validated here as a data-quality gate before adoption proceeds.
|
||||
LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
|
||||
// 3) corrKanaal's legacy 'P' default is a "nobody actively chose"
|
||||
// sentinel, not evidence of a real preference - it still maps to
|
||||
// Post for display, we just never treat its mere presence as proof
|
||||
// of anything. An unrecognized channel throws rather than defaulting.
|
||||
var channel = dto.CorrKanaal switch
|
||||
{
|
||||
"P" => CorrespondenceChannel.Post,
|
||||
"E" => CorrespondenceChannel.Email,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"Legacy.UnrecognizedCorrKanaal", $"Unrecognized legacy corrKanaal '{dto.CorrKanaal}'."),
|
||||
};
|
||||
var contactDetails = new ContactDetails(dto.Email, dto.Telnr, channel);
|
||||
|
||||
// 4) four flat adres* columns -> Address?. Unlike the read-only
|
||||
// projection (LegacyCaseDetailProjection, which just displays legacy
|
||||
// data as-is), ADOPTION must fail loudly on a partial address rather
|
||||
// than silently treating it as "no address" - a partial address is a
|
||||
// real data-quality problem this row has, not a display nuance.
|
||||
Address? address = BuildAddressOrThrow(dto);
|
||||
|
||||
var diploma = new DiplomaEvidence(dto.DiplCd, dto.DiplLand, dto.DiplDat);
|
||||
var receivedOn = dto.DatOntv;
|
||||
|
||||
// 5) migrated is a bool - no implicit int conversion assumed (the
|
||||
// DTO already binds it as `bool` from JSON, so there is nothing to
|
||||
// coerce here; this comment documents that the trap was considered,
|
||||
// not skipped).
|
||||
_ = dto.Migrated;
|
||||
|
||||
if (dto.BeoordRes is null)
|
||||
{
|
||||
return RegistrationApplication.Create(
|
||||
Guid.NewGuid(), bsn, applicant, address, contactDetails, diploma, receivedOn);
|
||||
}
|
||||
|
||||
var outcome = dto.BeoordRes switch
|
||||
{
|
||||
"G" => AssessmentOutcome.Approved,
|
||||
"A" => AssessmentOutcome.Rejected,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"Legacy.UnrecognizedBeoordRes", $"Unrecognized legacy beoordRes '{dto.BeoordRes}'."),
|
||||
};
|
||||
|
||||
if (dto.DatBeoord is null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Legacy.MissingBeoordelingsdatum", "A recorded beoordRes requires a datBeoord.");
|
||||
}
|
||||
|
||||
// Legacy has no granular per-item verification checklist and no
|
||||
// rejection-category taxonomy - both are owned-side-only concepts.
|
||||
// We synthesize the minimum the domain requires to represent "this
|
||||
// was already assessed, verified through legacy's own (unmodeled)
|
||||
// process": an exception reason standing in for verifiedItems, and -
|
||||
// only for a Rejected outcome - a rejection category that is
|
||||
// deliberately NOT "Other"/"anders", so beoordMotiv is held to the
|
||||
// domain's normal 20-char minimum rather than the 50-char "Other"
|
||||
// minimum. (6) beoordMotiv may be shorter than that minimum - that's
|
||||
// expected, and Assessment.Create below will throw for it, which is
|
||||
// exactly the "surface as an adoption failure" behavior required.
|
||||
const string legacyVerificationNote = "Migrated from legacy system; verification recorded in legacy's own audit trail.";
|
||||
var rejectionCategory = outcome == AssessmentOutcome.Rejected ? "LegacyRejection" : null;
|
||||
|
||||
var application = RegistrationApplication.Create(
|
||||
Guid.NewGuid(), bsn, applicant, address, contactDetails, diploma, receivedOn);
|
||||
|
||||
application.RecordAssessment(
|
||||
outcome,
|
||||
dto.BeoordMotiv ?? string.Empty,
|
||||
verifiedItems: [],
|
||||
exceptionReason: legacyVerificationNote,
|
||||
rejectionCategory,
|
||||
dto.DatBeoord.Value);
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private static Address? BuildAddressOrThrow(LegacyAanvraagDto dto)
|
||||
{
|
||||
var parts = new[] { dto.AdresStr, dto.AdresNr, dto.AdresPc, dto.AdresPl };
|
||||
var presentCount = parts.Count(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (presentCount == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presentCount < parts.Length)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Address.AllPartsRequired",
|
||||
"This legacy row has a partial address (some but not all of street/number/postal code/city). " +
|
||||
"Adoption requires a complete address or none at all.");
|
||||
}
|
||||
|
||||
return new Address(dto.AdresStr!, dto.AdresNr!, dto.AdresPc!, dto.AdresPl!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's own `statCd` vocabulary ('O'|'B'|'A'|'X'), named - never left as
|
||||
/// bare characters. Used only for display (worklist bucket / process
|
||||
/// status), never as part of the domain aggregate: New.Domain has no
|
||||
/// business rules keyed on legacy's process stage, only on its own
|
||||
/// AssessmentOutcome once an assessment is actually recorded.
|
||||
/// </summary>
|
||||
internal enum LegacyAanvraagStatus
|
||||
{
|
||||
Open,
|
||||
Beoordeeld,
|
||||
Afgerond,
|
||||
Ingetrokken,
|
||||
}
|
||||
|
||||
internal static class LegacyAanvraagStatusMapper
|
||||
{
|
||||
public static LegacyAanvraagStatus Parse(string statCd) => statCd switch
|
||||
{
|
||||
"O" => LegacyAanvraagStatus.Open,
|
||||
"B" => LegacyAanvraagStatus.Beoordeeld,
|
||||
"A" => LegacyAanvraagStatus.Afgerond,
|
||||
"X" => LegacyAanvraagStatus.Ingetrokken,
|
||||
_ => throw new DomainInvariantViolationException("Legacy.UnrecognizedStatCd", $"Unrecognized legacy statCd '{statCd}'."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Thin wrapper around the legacy-backend HttpClient - the one place that knows its exact routes.</summary>
|
||||
internal sealed class LegacyBackendClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<LegacyAanvraagDto?> GetAanvraagAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.GetAsync($"/api/aanvragen/{aanvraagId}", ct);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<LegacyAanvraagDto>(ct);
|
||||
}
|
||||
|
||||
public async Task<List<LegacyAanvraagDto>> ListAanvragenAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<List<LegacyAanvraagDto>>("/api/aanvragen", ct);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task<LegacyDetailsWriteResponse> UpdateDetailsAsync(int aanvraagId, LegacyDetailsWriteRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PutAsJsonAsync($"/api/aanvragen/{aanvraagId}/gegevens", request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.Success();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.NotFound();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.Conflict();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadFromJsonAsync<LegacyValidationErrorResponse>(ct);
|
||||
return LegacyDetailsWriteResponse.ValidationFailed(body?.Errors ?? []);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
throw new InvalidOperationException("Unreachable - EnsureSuccessStatusCode throws for any non-2xx status.");
|
||||
}
|
||||
|
||||
public async Task SetMigratieVlagAsync(int aanvraagId, bool migrated, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PutAsJsonAsync(
|
||||
$"/api/aanvragen/{aanvraagId}/migratie-vlag", new MigratieVlagRequest(migrated), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record LegacyDetailsWriteResponse(
|
||||
LegacyDetailsWriteOutcome Outcome,
|
||||
List<LegacyValidationError>? Errors = null)
|
||||
{
|
||||
public static LegacyDetailsWriteResponse Success() => new(LegacyDetailsWriteOutcome.Success);
|
||||
public static LegacyDetailsWriteResponse NotFound() => new(LegacyDetailsWriteOutcome.NotFound);
|
||||
public static LegacyDetailsWriteResponse Conflict() => new(LegacyDetailsWriteOutcome.Conflict);
|
||||
|
||||
public static LegacyDetailsWriteResponse ValidationFailed(List<LegacyValidationError> errors) =>
|
||||
new(LegacyDetailsWriteOutcome.ValidationFailed, errors);
|
||||
}
|
||||
|
||||
internal enum LegacyDetailsWriteOutcome
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
Conflict,
|
||||
ValidationFailed,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// In-process counter of actual legacy HTTP calls, backing GET
|
||||
/// /api/diagnostics/legacy-call-count. Incremented exclusively by
|
||||
/// <see cref="LegacyCallCountingHandler"/> - a DelegatingHandler on the
|
||||
/// legacy-backend HttpClient - so every call through this client counts,
|
||||
/// with no risk of a call site forgetting to increment it by hand.
|
||||
/// </summary>
|
||||
public sealed class LegacyCallCounter
|
||||
{
|
||||
private long _count;
|
||||
|
||||
public long Count => Interlocked.Read(ref _count);
|
||||
|
||||
internal void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
internal sealed class LegacyCallCountingHandler(LegacyCallCounter counter) : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
counter.Increment();
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using New.Application.Worklist;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Shared legacy-row -> read-model projection, used by both LegacyCaseSource and LegacyWorklistReader.</summary>
|
||||
internal static class LegacyCaseDetailProjection
|
||||
{
|
||||
public static CaseDetail ToCaseDetail(LegacyAanvraagDto dto)
|
||||
{
|
||||
var status = LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
|
||||
AddressData? address = HasAllFourAddressParts(dto)
|
||||
? new AddressData(dto.AdresStr!, dto.AdresNr!, dto.AdresPc!, dto.AdresPl!)
|
||||
: null;
|
||||
|
||||
AssessmentData? assessment = dto.BeoordRes is not null
|
||||
? new AssessmentData(
|
||||
dto.BeoordRes == "G" ? "Approved" : "Rejected",
|
||||
dto.BeoordMotiv ?? string.Empty,
|
||||
VerifiedItems: [],
|
||||
ExceptionReason: null,
|
||||
RejectionCategory: null,
|
||||
dto.DatBeoord ?? dto.DatOntv)
|
||||
: null;
|
||||
|
||||
var preferredChannel = dto.CorrKanaal == "E" ? "Email" : "Post";
|
||||
|
||||
return new CaseDetail(
|
||||
WorklistOrigin.Legacy,
|
||||
dto.Id,
|
||||
RegistrationApplicationId: null,
|
||||
dto.Naam,
|
||||
dto.Voorl,
|
||||
dto.Bsn.Trim(),
|
||||
address,
|
||||
dto.Email,
|
||||
dto.Telnr,
|
||||
preferredChannel,
|
||||
dto.DiplCd,
|
||||
dto.DiplLand,
|
||||
dto.DiplDat,
|
||||
dto.DatOntv,
|
||||
assessment,
|
||||
ProcessStatus: status.ToString(),
|
||||
CaseFrameworkCaseId: null,
|
||||
Migrated: dto.Migrated,
|
||||
LastModifiedAt: AmsterdamClock.ToUtcOffset(dto.MutDat));
|
||||
}
|
||||
|
||||
public static WorklistItem ToWorklistItem(LegacyAanvraagDto dto)
|
||||
{
|
||||
var status = LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
var outcome = dto.BeoordRes switch { "G" => "Approved", "A" => "Rejected", _ => null };
|
||||
|
||||
return new WorklistItem(
|
||||
WorklistOrigin.Legacy,
|
||||
dto.Id,
|
||||
RegistrationApplicationId: null,
|
||||
dto.Naam,
|
||||
dto.Voorl,
|
||||
dto.Bsn.Trim(),
|
||||
dto.DatOntv,
|
||||
Bucket: status.ToString(),
|
||||
AssessmentOutcome: outcome,
|
||||
ProcessStatus: status.ToString(),
|
||||
LastModifiedAt: AmsterdamClock.ToUtcOffset(dto.MutDat),
|
||||
Migrated: dto.Migrated);
|
||||
}
|
||||
|
||||
private static bool HasAllFourAddressParts(LegacyAanvraagDto dto) =>
|
||||
!string.IsNullOrWhiteSpace(dto.AdresStr) &&
|
||||
!string.IsNullOrWhiteSpace(dto.AdresNr) &&
|
||||
!string.IsNullOrWhiteSpace(dto.AdresPc) &&
|
||||
!string.IsNullOrWhiteSpace(dto.AdresPl);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using New.Application.Ports;
|
||||
using New.Application.WriteThrough;
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="ILegacyCaseGateway"/>: the legacy-facing operations
|
||||
/// used by take-ownership, the write-through edit seam, and ownership
|
||||
/// release. Kept separate from <see cref="LegacyCaseSource"/> (seam A read,
|
||||
/// used only by the source resolver) - see that class's remarks.
|
||||
/// </summary>
|
||||
public sealed class LegacyCaseGateway : ILegacyCaseGateway
|
||||
{
|
||||
private readonly LegacyBackendClient _client;
|
||||
private readonly LegacyDetailsWriteThroughTranslator _translator;
|
||||
|
||||
// Internal constructor: see LegacyCaseSource's remarks. Registered via an
|
||||
// explicit factory in ServiceCollectionExtensions, not auto-construction.
|
||||
internal LegacyCaseGateway(LegacyBackendClient client, LegacyDetailsWriteThroughTranslator translator)
|
||||
{
|
||||
_client = client;
|
||||
_translator = translator;
|
||||
}
|
||||
|
||||
public async Task<LegacyFetchAndMapResult> FetchAndMapAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _client.GetAanvraagAsync(aanvraagId, ct);
|
||||
if (dto is null)
|
||||
{
|
||||
return new LegacyFetchAndMapResult(LegacyFetchStatus.NotFound, null);
|
||||
}
|
||||
|
||||
// Any DomainInvariantViolationException thrown by the mapper is
|
||||
// deliberately NOT caught here - it propagates to the caller
|
||||
// (TakeOwnershipHandler), which is exactly what "nothing is written
|
||||
// anywhere on a mapping failure" requires: this method only reads.
|
||||
var application = LegacyAanvraagMapper.ToDomain(dto);
|
||||
return new LegacyFetchAndMapResult(LegacyFetchStatus.Found, application);
|
||||
}
|
||||
|
||||
public async Task<WriteThroughOutcome> UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct)
|
||||
{
|
||||
var request = _translator.ToLegacyRequest(command);
|
||||
var response = await _client.UpdateDetailsAsync(aanvraagId, request, ct);
|
||||
|
||||
return response.Outcome switch
|
||||
{
|
||||
LegacyDetailsWriteOutcome.Success => WriteThroughOutcome.Success,
|
||||
LegacyDetailsWriteOutcome.NotFound => WriteThroughOutcome.NotFound,
|
||||
LegacyDetailsWriteOutcome.Conflict => WriteThroughOutcome.Conflict,
|
||||
LegacyDetailsWriteOutcome.ValidationFailed => WriteThroughOutcome.ValidationFailed(
|
||||
_translator.ToPortalErrors(response.Errors ?? [])),
|
||||
_ => throw new InvalidOperationException($"Unhandled legacy write-through outcome '{response.Outcome}'."),
|
||||
};
|
||||
}
|
||||
|
||||
public Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct) =>
|
||||
_client.SetMigratieVlagAsync(aanvraagId, migrated, ct);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Seam A single-case read: GET /api/aanvragen/{id}. Deliberately a concrete
|
||||
/// class with no interface of its own - like OwnedApplicationSource
|
||||
/// (New.Infrastructure.Persistence), it exists only to be injected into the
|
||||
/// source resolver (New.Api), which is the only type allowed to reference
|
||||
/// both of them (Architecture.Tests rule 7).
|
||||
/// </summary>
|
||||
public sealed class LegacyCaseSource
|
||||
{
|
||||
private readonly LegacyBackendClient _client;
|
||||
|
||||
// Internal constructor: LegacyBackendClient's own API surface uses the
|
||||
// internal LegacyAanvraagDto, so it can't be a public constructor
|
||||
// parameter on this public class. DI can still call an internal
|
||||
// constructor from another assembly via reflection.
|
||||
internal LegacyCaseSource(LegacyBackendClient client) => _client = client;
|
||||
|
||||
public async Task<CaseDetail?> GetAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _client.GetAanvraagAsync(aanvraagId, ct);
|
||||
return dto is null ? null : LegacyCaseDetailProjection.ToCaseDetail(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using New.Application.WriteThrough;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Seam B's write-through translator (PUT .../gegevens).
|
||||
///
|
||||
/// CRITICAL CONSTRAINT (this becomes ADR-002): this translator must contain
|
||||
/// NO business rules. No conditionals on request values, no validation
|
||||
/// beyond null/shape checks, no derived values, no defaulting. Legacy is the
|
||||
/// sole authority on these rules - every "is this actually valid" decision
|
||||
/// happens on the other side of the HTTP call, and this class only reshapes
|
||||
/// the request/response, it never second-guesses them.
|
||||
/// </summary>
|
||||
internal sealed class LegacyDetailsWriteThroughTranslator(ILogger<LegacyDetailsWriteThroughTranslator> logger)
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> FieldPathsByLegacyVeld = new Dictionary<string, string>
|
||||
{
|
||||
["NAAM"] = "surname",
|
||||
["ADRES_PC"] = "address.postalCode",
|
||||
["ADRES_NR"] = "address.number",
|
||||
["EMAIL"] = "email",
|
||||
["TELNR"] = "phone",
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> MessagesByLegacyCode = new Dictionary<string, string>
|
||||
{
|
||||
// Pure lookup table, not a rule engine - the message text is
|
||||
// presentation only, the pass/fail decision already happened in legacy.
|
||||
};
|
||||
|
||||
public LegacyDetailsWriteRequest ToLegacyRequest(ApplicantDetailsCommand command) => new(
|
||||
command.Surname,
|
||||
command.Initials,
|
||||
command.Address is { } a ? new LegacyAddressWriteRequest(a.Street, a.Number, a.PostalCode, a.City) : null,
|
||||
command.Email,
|
||||
command.Phone,
|
||||
command.PreferredChannel);
|
||||
|
||||
public IReadOnlyList<PortalFieldError> ToPortalErrors(IEnumerable<LegacyValidationError> legacyErrors) =>
|
||||
legacyErrors.Select(ToPortalError).ToList();
|
||||
|
||||
private PortalFieldError ToPortalError(LegacyValidationError error)
|
||||
{
|
||||
if (!FieldPathsByLegacyVeld.TryGetValue(error.Veld, out var fieldPath))
|
||||
{
|
||||
// Unrecognized `veld` - never throw/crash, just fall back to a
|
||||
// generic field path and surface legacy's own message verbatim
|
||||
// via `detail`, plus a warning so it gets noticed and the lookup
|
||||
// table above extended.
|
||||
logger.LogWarning(
|
||||
"Unrecognized legacy validation veld '{Veld}' (code '{Code}'): {Melding}",
|
||||
error.Veld, error.Code, error.Melding);
|
||||
|
||||
return new PortalFieldError(
|
||||
Field: error.Veld,
|
||||
Message: "This field could not be saved; see detail for the legacy system's message.",
|
||||
Detail: error.Melding);
|
||||
}
|
||||
|
||||
var message = MessagesByLegacyCode.TryGetValue(error.Code, out var known)
|
||||
? known
|
||||
: error.Melding;
|
||||
|
||||
return new PortalFieldError(fieldPath, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Seam A list read: GET /api/aanvragen, for the merged worklist.</summary>
|
||||
public sealed class LegacyWorklistReader : ILegacyWorklistReader
|
||||
{
|
||||
private readonly LegacyBackendClient _client;
|
||||
|
||||
// Internal constructor: see LegacyCaseSource's remarks. Registered via an
|
||||
// explicit factory in ServiceCollectionExtensions, not auto-construction.
|
||||
internal LegacyWorklistReader(LegacyBackendClient client) => _client = client;
|
||||
|
||||
public async Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await _client.ListAanvragenAsync(ct);
|
||||
return rows.Select(LegacyCaseDetailProjection.ToWorklistItem).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Infrastructure.Legacy</RootNamespace>
|
||||
<!--
|
||||
Legacy DTOs in this project are `internal` on purpose (Architecture.Tests
|
||||
rule 3). No InternalsVisibleTo is granted anywhere: Architecture.Tests
|
||||
inspects the compiled assembly's metadata (NetArchTest/Mono.Cecil), which
|
||||
sees internal types regardless of visibility to the caller, so nothing
|
||||
outside this project can ever reference these DTOs by design.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace New.Infrastructure.Legacy.Options;
|
||||
|
||||
public sealed class LegacyBackendOptions
|
||||
{
|
||||
public const string SectionName = "Services:LegacyBackend";
|
||||
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.Legacy.Options;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for this project - see
|
||||
/// New.Infrastructure.Persistence.ServiceCollectionExtensions for why
|
||||
/// Program.cs only ever calls extension methods like this one, never names
|
||||
/// LegacyCaseSource/OwnedApplicationSource directly itself.
|
||||
///
|
||||
/// Every service below that depends on an `internal` type (LegacyBackendClient,
|
||||
/// LegacyDetailsWriteThroughTranslator - both internal because their APIs are
|
||||
/// shaped by legacy DTOs, see Architecture.Tests rule 3) is registered via an
|
||||
/// explicit factory delegate rather than `services.AddScoped<T>()`'s
|
||||
/// automatic constructor discovery. That auto-discovery goes through
|
||||
/// reflection in a different assembly and is not guaranteed to see
|
||||
/// non-public constructors; a factory delegate compiled here, in the same
|
||||
/// assembly, calls the constructor directly under ordinary C# accessibility
|
||||
/// rules - no reflection involved, so there's nothing to be uncertain about.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string HttpClientName = "LegacyBackend";
|
||||
|
||||
public static IServiceCollection AddLegacyInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<LegacyBackendOptions>(configuration.GetSection(LegacyBackendOptions.SectionName));
|
||||
|
||||
services.AddSingleton<LegacyCallCounter>();
|
||||
services.AddTransient<LegacyCallCountingHandler>();
|
||||
|
||||
services.AddHttpClient(HttpClientName, (sp, http) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<LegacyBackendOptions>>().Value;
|
||||
http.BaseAddress = new Uri(options.BaseUrl);
|
||||
}).AddHttpMessageHandler<LegacyCallCountingHandler>();
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new LegacyBackendClient(sp.GetRequiredService<IHttpClientFactory>().CreateClient(HttpClientName)));
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new LegacyDetailsWriteThroughTranslator(sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<LegacyDetailsWriteThroughTranslator>>()));
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new LegacyCaseSource(sp.GetRequiredService<LegacyBackendClient>()));
|
||||
|
||||
services.AddScoped<ILegacyWorklistReader>(sp =>
|
||||
new LegacyWorklistReader(sp.GetRequiredService<LegacyBackendClient>()));
|
||||
|
||||
services.AddScoped<ILegacyCaseGateway>(sp =>
|
||||
new LegacyCaseGateway(
|
||||
sp.GetRequiredService<LegacyBackendClient>(),
|
||||
sp.GetRequiredService<LegacyDetailsWriteThroughTranslator>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using New.Application.Worklist;
|
||||
using New.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace New.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>Shared record -> read-model projection, used by both OwnedApplicationSource and OwnedWorklistReader.</summary>
|
||||
internal static class CaseDetailProjection
|
||||
{
|
||||
public static CaseDetail FromRecord(RegistrationApplicationRecord record, int? legacyAanvraagId)
|
||||
{
|
||||
AddressData? address = record.AddressStreet is not null
|
||||
? new AddressData(record.AddressStreet, record.AddressNumber!, record.AddressPostalCode!, record.AddressCity!)
|
||||
: null;
|
||||
|
||||
AssessmentData? assessment = record.AssessmentOutcome is not null
|
||||
? new AssessmentData(
|
||||
record.AssessmentOutcome,
|
||||
record.AssessmentMotivation ?? string.Empty,
|
||||
record.AssessmentVerifiedItems,
|
||||
record.AssessmentExceptionReason,
|
||||
record.AssessmentRejectionCategory,
|
||||
record.AssessmentDecidedOn ?? record.ReceivedOn)
|
||||
: null;
|
||||
|
||||
return new CaseDetail(
|
||||
WorklistOrigin.Owned,
|
||||
legacyAanvraagId,
|
||||
record.Id,
|
||||
record.Surname,
|
||||
record.Initials,
|
||||
record.Bsn,
|
||||
address,
|
||||
record.Email,
|
||||
record.Phone,
|
||||
record.PreferredChannel,
|
||||
record.DiplomaCode,
|
||||
record.DiplomaCountryOfIssue,
|
||||
record.DiplomaIssuedOn,
|
||||
record.ReceivedOn,
|
||||
assessment,
|
||||
record.CaseProcessStatus,
|
||||
record.CaseFrameworkCaseId,
|
||||
Migrated: legacyAanvraagId is not null);
|
||||
}
|
||||
|
||||
public static WorklistItem ToWorklistItem(RegistrationApplicationRecord record, int? legacyAanvraagId)
|
||||
{
|
||||
// Owned cases don't carry the legacy statCd vocabulary. As a
|
||||
// pragmatic simplification for the merged worklist's `bucket`
|
||||
// filter, we mirror it loosely: no assessment yet reads as "Open",
|
||||
// any recorded assessment reads as "Beoordeeld" - the same two
|
||||
// bucket labels legacy uses for the equivalent stages.
|
||||
var bucket = record.AssessmentOutcome is null ? "Open" : "Beoordeeld";
|
||||
|
||||
return new WorklistItem(
|
||||
WorklistOrigin.Owned,
|
||||
legacyAanvraagId,
|
||||
record.Id,
|
||||
record.Surname,
|
||||
record.Initials,
|
||||
record.Bsn,
|
||||
record.ReceivedOn,
|
||||
bucket,
|
||||
record.AssessmentOutcome,
|
||||
record.CaseProcessStatus);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using New.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace New.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>Maps exactly to the `legacy_ownership` schema given in the design notes.</summary>
|
||||
public sealed class LegacyOwnershipRowConfiguration : IEntityTypeConfiguration<LegacyOwnershipRow>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LegacyOwnershipRow> builder)
|
||||
{
|
||||
builder.ToTable("legacy_ownership");
|
||||
|
||||
builder.HasKey(x => x.LegacyAanvraagId);
|
||||
builder.Property(x => x.LegacyAanvraagId)
|
||||
.HasColumnName("legacy_aanvraag_id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(x => x.RegistrationApplicationId)
|
||||
.HasColumnName("registration_application_id")
|
||||
.IsRequired();
|
||||
builder.HasIndex(x => x.RegistrationApplicationId).IsUnique();
|
||||
|
||||
// Schema specifies TIMESTAMP (without time zone), not TIMESTAMPTZ.
|
||||
// We always deal in UTC instants (TimeProvider.GetUtcNow()), so we
|
||||
// store the UTC instant as a naive timestamp rather than widening the
|
||||
// column to timestamptz - the offset is always zero by construction.
|
||||
builder.Property(x => x.TakenOverAt)
|
||||
.HasColumnName("taken_over_at")
|
||||
.HasColumnType("timestamp")
|
||||
.HasConversion(
|
||||
// Npgsql rejects a Kind=Utc DateTime against a "timestamp
|
||||
// without time zone" column (it only accepts Kind=Unspecified
|
||||
// there, to avoid silently implying a timezone the column
|
||||
// doesn't have) - strip the Kind marker, the offset is always
|
||||
// zero by construction so no information is lost.
|
||||
toProvider => DateTime.SpecifyKind(toProvider.UtcDateTime, DateTimeKind.Unspecified),
|
||||
fromProvider => new DateTimeOffset(DateTime.SpecifyKind(fromProvider, DateTimeKind.Utc)))
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.DomainWritesSince)
|
||||
.HasColumnName("domain_writes_since")
|
||||
.HasDefaultValue(0)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using New.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace New.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public sealed class RegistrationApplicationRecordConfiguration : IEntityTypeConfiguration<RegistrationApplicationRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RegistrationApplicationRecord> builder)
|
||||
{
|
||||
builder.ToTable("registration_applications");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Bsn).HasMaxLength(9).IsRequired();
|
||||
builder.Property(x => x.Surname).IsRequired();
|
||||
builder.Property(x => x.Initials).IsRequired();
|
||||
builder.Property(x => x.PreferredChannel).IsRequired();
|
||||
builder.Property(x => x.DiplomaCode).IsRequired();
|
||||
builder.Property(x => x.DiplomaCountryOfIssue).IsRequired();
|
||||
|
||||
// Npgsql maps List<string> to a native Postgres text[] column.
|
||||
builder.Property(x => x.AssessmentVerifiedItems).HasColumnType("text[]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace New.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence-only row type for the `legacy_ownership` table. Deliberately
|
||||
/// NOT a domain concept (the spec calls it "a simple table, not an
|
||||
/// aggregate") - plain data, no behavior, no invariants of its own beyond
|
||||
/// what SQL constraints already express.
|
||||
/// </summary>
|
||||
public sealed class LegacyOwnershipRow
|
||||
{
|
||||
public int LegacyAanvraagId { get; set; }
|
||||
public Guid RegistrationApplicationId { get; set; }
|
||||
public DateTimeOffset TakenOverAt { get; set; }
|
||||
public int DomainWritesSince { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace New.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence model for the <see cref="New.Domain.RegistrationApplication"/>
|
||||
/// aggregate. Deliberately a separate, plain, mutable class rather than
|
||||
/// mapping the rich aggregate (private setters, no parameterless
|
||||
/// constructor, records-as-owned-types) straight into EF Core - that would
|
||||
/// either force EF-shaped constructor parameters onto the domain type or
|
||||
/// fight EF's constructor-binding conventions for no real benefit. Instead,
|
||||
/// RegistrationApplicationRepository translates explicitly in both
|
||||
/// directions, keeping New.Domain entirely free of any EF Core awareness.
|
||||
/// </summary>
|
||||
public sealed class RegistrationApplicationRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Bsn { get; set; } = string.Empty;
|
||||
|
||||
public string Surname { get; set; } = string.Empty;
|
||||
public string Initials { get; set; } = string.Empty;
|
||||
|
||||
public string? AddressStreet { get; set; }
|
||||
public string? AddressNumber { get; set; }
|
||||
public string? AddressPostalCode { get; set; }
|
||||
public string? AddressCity { get; set; }
|
||||
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string PreferredChannel { get; set; } = string.Empty;
|
||||
|
||||
public string DiplomaCode { get; set; } = string.Empty;
|
||||
public string DiplomaCountryOfIssue { get; set; } = string.Empty;
|
||||
public DateOnly DiplomaIssuedOn { get; set; }
|
||||
|
||||
public string? AssessmentOutcome { get; set; }
|
||||
public string? AssessmentMotivation { get; set; }
|
||||
public List<string> AssessmentVerifiedItems { get; set; } = [];
|
||||
public string? AssessmentExceptionReason { get; set; }
|
||||
public string? AssessmentRejectionCategory { get; set; }
|
||||
public DateOnly? AssessmentDecidedOn { get; set; }
|
||||
|
||||
public Guid? CaseFrameworkCaseId { get; set; }
|
||||
public string? CaseExternalReference { get; set; }
|
||||
public string? CaseProcessStatus { get; set; }
|
||||
|
||||
public DateOnly ReceivedOn { get; set; }
|
||||
}
|
||||
Generated
+147
@@ -0,0 +1,147 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using New.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace New.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(NewDbContext))]
|
||||
[Migration("20260730161623_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("New.Infrastructure.Persistence.Entities.LegacyOwnershipRow", b =>
|
||||
{
|
||||
b.Property<int>("LegacyAanvraagId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("legacy_aanvraag_id");
|
||||
|
||||
b.Property<int>("DomainWritesSince")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("domain_writes_since");
|
||||
|
||||
b.Property<Guid>("RegistrationApplicationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("registration_application_id");
|
||||
|
||||
b.Property<DateTime>("TakenOverAt")
|
||||
.HasColumnType("timestamp")
|
||||
.HasColumnName("taken_over_at");
|
||||
|
||||
b.HasKey("LegacyAanvraagId");
|
||||
|
||||
b.HasIndex("RegistrationApplicationId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("legacy_ownership", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("New.Infrastructure.Persistence.Entities.RegistrationApplicationRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AddressCity")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AddressNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AddressPostalCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AddressStreet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly?>("AssessmentDecidedOn")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("AssessmentExceptionReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AssessmentMotivation")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AssessmentOutcome")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AssessmentRejectionCategory")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("AssessmentVerifiedItems")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("Bsn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b.Property<string>("CaseExternalReference")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("CaseFrameworkCaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CaseProcessStatus")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DiplomaCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DiplomaCountryOfIssue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("DiplomaIssuedOn")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Initials")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PreferredChannel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("ReceivedOn")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Surname")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("registration_applications", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace New.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "legacy_ownership",
|
||||
columns: table => new
|
||||
{
|
||||
legacy_aanvraag_id = table.Column<int>(type: "integer", nullable: false),
|
||||
registration_application_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
taken_over_at = table.Column<DateTime>(type: "timestamp", nullable: false),
|
||||
domain_writes_since = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_legacy_ownership", x => x.legacy_aanvraag_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "registration_applications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Bsn = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
|
||||
Surname = table.Column<string>(type: "text", nullable: false),
|
||||
Initials = table.Column<string>(type: "text", nullable: false),
|
||||
AddressStreet = table.Column<string>(type: "text", nullable: true),
|
||||
AddressNumber = table.Column<string>(type: "text", nullable: true),
|
||||
AddressPostalCode = table.Column<string>(type: "text", nullable: true),
|
||||
AddressCity = table.Column<string>(type: "text", nullable: true),
|
||||
Email = table.Column<string>(type: "text", nullable: true),
|
||||
Phone = table.Column<string>(type: "text", nullable: true),
|
||||
PreferredChannel = table.Column<string>(type: "text", nullable: false),
|
||||
DiplomaCode = table.Column<string>(type: "text", nullable: false),
|
||||
DiplomaCountryOfIssue = table.Column<string>(type: "text", nullable: false),
|
||||
DiplomaIssuedOn = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
AssessmentOutcome = table.Column<string>(type: "text", nullable: true),
|
||||
AssessmentMotivation = table.Column<string>(type: "text", nullable: true),
|
||||
AssessmentVerifiedItems = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
AssessmentExceptionReason = table.Column<string>(type: "text", nullable: true),
|
||||
AssessmentRejectionCategory = table.Column<string>(type: "text", nullable: true),
|
||||
AssessmentDecidedOn = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
CaseFrameworkCaseId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CaseExternalReference = table.Column<string>(type: "text", nullable: true),
|
||||
CaseProcessStatus = table.Column<string>(type: "text", nullable: true),
|
||||
ReceivedOn = table.Column<DateOnly>(type: "date", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_registration_applications", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_legacy_ownership_registration_application_id",
|
||||
table: "legacy_ownership",
|
||||
column: "registration_application_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "legacy_ownership");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "registration_applications");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using New.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace New.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(NewDbContext))]
|
||||
partial class NewDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("New.Infrastructure.Persistence.Entities.LegacyOwnershipRow", b =>
|
||||
{
|
||||
b.Property<int>("LegacyAanvraagId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("legacy_aanvraag_id");
|
||||
|
||||
b.Property<int>("DomainWritesSince")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("domain_writes_since");
|
||||
|
||||
b.Property<Guid>("RegistrationApplicationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("registration_application_id");
|
||||
|
||||
b.Property<DateTime>("TakenOverAt")
|
||||
.HasColumnType("timestamp")
|
||||
.HasColumnName("taken_over_at");
|
||||
|
||||
b.HasKey("LegacyAanvraagId");
|
||||
|
||||
b.HasIndex("RegistrationApplicationId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("legacy_ownership", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("New.Infrastructure.Persistence.Entities.RegistrationApplicationRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AddressCity")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AddressNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AddressPostalCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AddressStreet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly?>("AssessmentDecidedOn")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("AssessmentExceptionReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AssessmentMotivation")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AssessmentOutcome")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AssessmentRejectionCategory")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("AssessmentVerifiedItems")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("Bsn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b.Property<string>("CaseExternalReference")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("CaseFrameworkCaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CaseProcessStatus")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DiplomaCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DiplomaCountryOfIssue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("DiplomaIssuedOn")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Initials")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PreferredChannel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("ReceivedOn")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Surname")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("registration_applications", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Infrastructure.Persistence</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Npgsql provider ONLY. Microsoft.EntityFrameworkCore.SqlServer must never
|
||||
be referenced anywhere under new/ - this boundary keeps the "new" stack
|
||||
structurally unable to reach the legacy SQL Server engine, even in
|
||||
principle. Enforced by Architecture.Tests rule 9.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace New.Infrastructure.Persistence;
|
||||
|
||||
public sealed class NewDbContext(DbContextOptions<NewDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<RegistrationApplicationRecord> RegistrationApplications => Set<RegistrationApplicationRecord>();
|
||||
|
||||
public DbSet<LegacyOwnershipRow> LegacyOwnership => Set<LegacyOwnershipRow>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(NewDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace New.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time-only factory so `dotnet ef migrations add/update` can
|
||||
/// construct a NewDbContext without a running host (this project has no DI
|
||||
/// container of its own - New.Api's ServiceCollectionExtensions.AddPersistenceInfrastructure
|
||||
/// is what wires the real DbContextOptions at runtime, reading
|
||||
/// ConnectionStrings__New from the environment). Never used outside `dotnet ef`.
|
||||
/// </summary>
|
||||
public sealed class NewDbContextFactory : IDesignTimeDbContextFactory<NewDbContext>
|
||||
{
|
||||
public NewDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<NewDbContext>();
|
||||
optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=newdb;Username=postgres;Password=postgres");
|
||||
return new NewDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single owned case by its <c>RegistrationApplicationId</c>,
|
||||
/// projected straight from the stored record (no need to reconstruct and
|
||||
/// re-validate the domain aggregate just to read it back).
|
||||
///
|
||||
/// Deliberately a concrete class with no interface of its own - it exists
|
||||
/// only to be injected into the source resolver (New.Api) alongside
|
||||
/// LegacyCaseSource, and Architecture.Tests rule 7 asserts the resolver is
|
||||
/// the only type that references both of them.
|
||||
/// </summary>
|
||||
public sealed class OwnedApplicationSource(NewDbContext db)
|
||||
{
|
||||
public async Task<CaseDetail?> GetAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var record = await db.RegistrationApplications.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == registrationApplicationId, ct);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ownership = await db.LegacyOwnership.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct);
|
||||
|
||||
return CaseDetailProjection.FromRecord(record, ownership?.LegacyAanvraagId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public sealed class OwnedWorklistReader(NewDbContext db) : IOwnedWorklistReader
|
||||
{
|
||||
public async Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var records = await db.RegistrationApplications.AsNoTracking().ToListAsync(ct);
|
||||
var ownershipByOwnedId = await db.LegacyOwnership.AsNoTracking()
|
||||
.ToDictionaryAsync(x => x.RegistrationApplicationId, x => x.LegacyAanvraagId, ct);
|
||||
|
||||
return records
|
||||
.Select(r => CaseDetailProjection.ToWorklistItem(
|
||||
r,
|
||||
ownershipByOwnedId.TryGetValue(r.Id, out var legacyId) ? legacyId : null))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace New.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public sealed class OwnershipRegistry(NewDbContext db) : IOwnershipRegistry
|
||||
{
|
||||
public async Task<Guid?> LookupOwnedIdAsync(int legacyAanvraagId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.LegacyOwnership.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.LegacyAanvraagId == legacyAanvraagId, ct);
|
||||
return row?.RegistrationApplicationId;
|
||||
}
|
||||
|
||||
public async Task<OwnershipRecord?> GetAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.LegacyOwnership.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct);
|
||||
|
||||
return row is null
|
||||
? null
|
||||
: new OwnershipRecord(row.LegacyAanvraagId, row.RegistrationApplicationId, row.TakenOverAt, row.DomainWritesSince);
|
||||
}
|
||||
|
||||
public Task RecordAsync(int legacyAanvraagId, Guid registrationApplicationId, DateTimeOffset takenOverAt, CancellationToken ct)
|
||||
{
|
||||
db.LegacyOwnership.Add(new LegacyOwnershipRow
|
||||
{
|
||||
LegacyAanvraagId = legacyAanvraagId,
|
||||
RegistrationApplicationId = registrationApplicationId,
|
||||
TakenOverAt = takenOverAt,
|
||||
DomainWritesSince = 0,
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task IncrementDomainWritesAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.LegacyOwnership.FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct);
|
||||
if (row is not null)
|
||||
{
|
||||
row.DomainWritesSince += 1;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.LegacyOwnership.FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct);
|
||||
if (row is not null)
|
||||
{
|
||||
db.LegacyOwnership.Remove(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
using New.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace New.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-backed adapter for <see cref="IRegistrationApplicationRepository"/>.
|
||||
///
|
||||
/// Because <see cref="RegistrationApplication"/> is a rich domain object (not
|
||||
/// itself an EF entity - see the remarks on <see cref="RegistrationApplicationRecord"/>),
|
||||
/// this repository keeps track of every aggregate it has handed out or
|
||||
/// staged for insertion, alongside its backing record. <see cref="FlushTrackedChangesToRecords"/>
|
||||
/// re-copies each tracked aggregate's current state into its record right
|
||||
/// before <see cref="UnitOfWork"/> calls SaveChangesAsync, so mutations made
|
||||
/// through the aggregate's own methods (RecordAssessment, UpdateApplicantDetails,
|
||||
/// AttachCaseReference, ...) are what actually gets persisted - not a second,
|
||||
/// independently-mutated copy.
|
||||
/// </summary>
|
||||
public sealed class RegistrationApplicationRepository(NewDbContext db) : IRegistrationApplicationRepository
|
||||
{
|
||||
private readonly List<(RegistrationApplication Domain, RegistrationApplicationRecord Record)> _tracked = [];
|
||||
|
||||
public async Task<RegistrationApplication?> GetAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var record = await db.RegistrationApplications
|
||||
.FirstOrDefaultAsync(x => x.Id == registrationApplicationId, ct);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var domain = ToDomain(record);
|
||||
_tracked.Add((domain, record));
|
||||
return domain;
|
||||
}
|
||||
|
||||
public Task AddAsync(RegistrationApplication application, CancellationToken ct)
|
||||
{
|
||||
var record = new RegistrationApplicationRecord { Id = application.RegistrationApplicationId };
|
||||
Populate(record, application);
|
||||
db.RegistrationApplications.Add(record);
|
||||
_tracked.Add((application, record));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(RegistrationApplication application, CancellationToken ct)
|
||||
{
|
||||
var record = await db.RegistrationApplications
|
||||
.FirstOrDefaultAsync(x => x.Id == application.RegistrationApplicationId, ct);
|
||||
|
||||
if (record is not null)
|
||||
{
|
||||
db.RegistrationApplications.Remove(record);
|
||||
}
|
||||
|
||||
_tracked.RemoveAll(t => t.Domain.RegistrationApplicationId == application.RegistrationApplicationId);
|
||||
}
|
||||
|
||||
/// <summary>Called by <see cref="UnitOfWork"/> immediately before SaveChangesAsync.</summary>
|
||||
internal void FlushTrackedChangesToRecords()
|
||||
{
|
||||
foreach (var (domain, record) in _tracked)
|
||||
{
|
||||
Populate(record, domain);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Populate(RegistrationApplicationRecord record, RegistrationApplication domain)
|
||||
{
|
||||
record.Bsn = domain.Bsn.Value;
|
||||
record.Surname = domain.Applicant.Surname;
|
||||
record.Initials = domain.Applicant.Initials;
|
||||
|
||||
record.AddressStreet = domain.CorrespondenceAddress?.Street;
|
||||
record.AddressNumber = domain.CorrespondenceAddress?.Number;
|
||||
record.AddressPostalCode = domain.CorrespondenceAddress?.PostalCode;
|
||||
record.AddressCity = domain.CorrespondenceAddress?.City;
|
||||
|
||||
record.Email = domain.ContactDetails.Email;
|
||||
record.Phone = domain.ContactDetails.Phone;
|
||||
record.PreferredChannel = domain.ContactDetails.PreferredChannel.ToString();
|
||||
|
||||
record.DiplomaCode = domain.DiplomaEvidence.Code;
|
||||
record.DiplomaCountryOfIssue = domain.DiplomaEvidence.CountryOfIssue;
|
||||
record.DiplomaIssuedOn = domain.DiplomaEvidence.IssuedOn;
|
||||
|
||||
record.AssessmentOutcome = domain.Assessment?.Outcome.ToString();
|
||||
record.AssessmentMotivation = domain.Assessment?.Motivation;
|
||||
record.AssessmentVerifiedItems = domain.Assessment?.VerifiedItems.ToList() ?? [];
|
||||
record.AssessmentExceptionReason = domain.Assessment?.ExceptionReason;
|
||||
record.AssessmentRejectionCategory = domain.Assessment?.RejectionCategory;
|
||||
record.AssessmentDecidedOn = domain.Assessment?.DecidedOn;
|
||||
|
||||
record.CaseFrameworkCaseId = domain.Case?.FrameworkCaseId;
|
||||
record.CaseExternalReference = domain.Case?.ExternalReference;
|
||||
record.CaseProcessStatus = domain.Case?.ProcessStatus;
|
||||
|
||||
record.ReceivedOn = domain.ReceivedOn;
|
||||
}
|
||||
|
||||
internal static RegistrationApplication ToDomain(RegistrationApplicationRecord record)
|
||||
{
|
||||
var bsn = new Bsn(record.Bsn);
|
||||
var applicant = new PersonName(record.Surname, record.Initials);
|
||||
var address = HasAllFourAddressParts(record)
|
||||
? new Address(record.AddressStreet!, record.AddressNumber!, record.AddressPostalCode!, record.AddressCity!)
|
||||
: null;
|
||||
var channel = Enum.Parse<CorrespondenceChannel>(record.PreferredChannel);
|
||||
var contactDetails = new ContactDetails(record.Email, record.Phone, channel);
|
||||
var diploma = new DiplomaEvidence(record.DiplomaCode, record.DiplomaCountryOfIssue, record.DiplomaIssuedOn);
|
||||
|
||||
var caseReference = record.CaseFrameworkCaseId is { } caseId
|
||||
? new CaseReference(caseId, record.CaseExternalReference ?? string.Empty, record.CaseProcessStatus)
|
||||
: null;
|
||||
|
||||
RegistrationApplication application;
|
||||
if (record.AssessmentOutcome is { } outcomeText)
|
||||
{
|
||||
var assessment = Assessment.Create(
|
||||
Enum.Parse<AssessmentOutcome>(outcomeText),
|
||||
record.AssessmentMotivation ?? string.Empty,
|
||||
record.AssessmentVerifiedItems,
|
||||
record.AssessmentExceptionReason,
|
||||
record.AssessmentRejectionCategory,
|
||||
record.AssessmentDecidedOn ?? record.ReceivedOn);
|
||||
|
||||
application = RegistrationApplication.CreateWithAssessment(
|
||||
record.Id, bsn, applicant, address, contactDetails, diploma, record.ReceivedOn, assessment, caseReference);
|
||||
}
|
||||
else
|
||||
{
|
||||
application = RegistrationApplication.Create(
|
||||
record.Id, bsn, applicant, address, contactDetails, diploma, record.ReceivedOn, caseReference);
|
||||
}
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private static bool HasAllFourAddressParts(RegistrationApplicationRecord record) =>
|
||||
record.AddressStreet is not null &&
|
||||
record.AddressNumber is not null &&
|
||||
record.AddressPostalCode is not null &&
|
||||
record.AddressCity is not null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using New.Application.Ports;
|
||||
|
||||
namespace New.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public sealed class UnitOfWork(NewDbContext db, RegistrationApplicationRepository repository) : IUnitOfWork
|
||||
{
|
||||
public Task SaveChangesAsync(CancellationToken ct)
|
||||
{
|
||||
// Re-copy every tracked aggregate's current state into its record
|
||||
// before committing - see RegistrationApplicationRepository's remarks.
|
||||
repository.FlushTrackedChangesToRecords();
|
||||
return db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.Persistence.Repositories;
|
||||
|
||||
namespace New.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for this project. Program.cs calls only this
|
||||
/// extension method and never names OwnedApplicationSource/LegacyCaseSource
|
||||
/// (from New.Infrastructure.Legacy) directly - that keeps Program.cs itself
|
||||
/// from being a second type that references both "source" types, which would
|
||||
/// undermine the resolver-exclusivity asserted by Architecture.Tests rule 7.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPersistenceInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<NewDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("New")));
|
||||
|
||||
services.AddScoped<RegistrationApplicationRepository>();
|
||||
services.AddScoped<IRegistrationApplicationRepository>(sp => sp.GetRequiredService<RegistrationApplicationRepository>());
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
services.AddScoped<IOwnershipRegistry, OwnershipRegistry>();
|
||||
services.AddScoped<IOwnedWorklistReader, OwnedWorklistReader>();
|
||||
services.AddScoped<OwnedApplicationSource>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Architecture.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Test-only exception to the normal dependency direction: this project
|
||||
references every New.* project so it can assert on their compiled
|
||||
assemblies (NetArchTest inspects IL metadata, not source).
|
||||
-->
|
||||
<ProjectReference Include="..\..\src\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\New.Application\New.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\New.Infrastructure.Persistence\New.Infrastructure.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\src\New.Infrastructure.Legacy\New.Infrastructure.Legacy.csproj" />
|
||||
<ProjectReference Include="..\..\src\New.Infrastructure.CaseFramework\New.Infrastructure.CaseFramework.csproj" />
|
||||
<ProjectReference Include="..\..\src\New.Api\New.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Reflection;
|
||||
using NetArchTest.Rules;
|
||||
using New.Application.Ownership;
|
||||
using New.Infrastructure.CaseFramework;
|
||||
using New.Infrastructure.Legacy;
|
||||
using New.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace Architecture.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes §10's architecture rules as build-failing assertions. A demo that
|
||||
/// passes the smoke script but fails these has demonstrated nothing - the
|
||||
/// seam boundaries are the point, not an implementation detail.
|
||||
/// </summary>
|
||||
public class ArchitectureTests
|
||||
{
|
||||
private static readonly Assembly DomainAssembly = typeof(New.Domain.RegistrationApplication).Assembly;
|
||||
private static readonly Assembly ApplicationAssembly = typeof(New.Application.Ports.IApplicationSource).Assembly;
|
||||
private static readonly Assembly PersistenceAssembly = typeof(NewDbContext).Assembly;
|
||||
private static readonly Assembly LegacyAssembly = typeof(LegacyCaseSource).Assembly;
|
||||
private static readonly Assembly CaseFrameworkAssembly = typeof(CaseFrameworkGateway).Assembly;
|
||||
private static readonly Assembly ApiAssembly = typeof(New.Api.Endpoints.WorklistEndpoints).Assembly;
|
||||
|
||||
private static readonly Assembly[] AllNewAssemblies =
|
||||
[
|
||||
DomainAssembly, ApplicationAssembly, PersistenceAssembly, LegacyAssembly, CaseFrameworkAssembly, ApiAssembly,
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Rule1_Domain_And_Application_Have_No_Dependency_On_CaseFramework()
|
||||
{
|
||||
var result = Types.InAssembly(DomainAssembly).Should().NotHaveDependencyOn("New.Infrastructure.CaseFramework").GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
|
||||
result = Types.InAssembly(ApplicationAssembly).Should().NotHaveDependencyOn("New.Infrastructure.CaseFramework").GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule2_Domain_And_Application_Have_No_Dependency_On_Legacy()
|
||||
{
|
||||
var result = Types.InAssembly(DomainAssembly).Should().NotHaveDependencyOn("New.Infrastructure.Legacy").GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
|
||||
result = Types.InAssembly(ApplicationAssembly).Should().NotHaveDependencyOn("New.Infrastructure.Legacy").GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule3_Legacy_Dtos_Are_Internal()
|
||||
{
|
||||
var result = Types.InAssembly(LegacyAssembly)
|
||||
.That().ResideInNamespace("New.Infrastructure.Legacy.Dtos")
|
||||
.Should().NotBePublic()
|
||||
.GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule4_CaseFramework_Dtos_Are_Internal()
|
||||
{
|
||||
var result = Types.InAssembly(CaseFrameworkAssembly)
|
||||
.That().ResideInNamespace("New.Infrastructure.CaseFramework.Dtos")
|
||||
.Should().NotBePublic()
|
||||
.GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
|
||||
{
|
||||
// Config-file concern, not code - see §10. Verified by inspection: the
|
||||
// only connection string anywhere under New.* is ConnectionStrings:New
|
||||
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
||||
// docker-compose.yml only ever injects ConnectionStrings__New into
|
||||
// new-backend. Nothing to assert against compiled IL here.
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule6_No_Public_Member_Named_Status_In_Domain()
|
||||
{
|
||||
var offendingMembers = DomainAssembly.GetTypes()
|
||||
.Where(t => t.IsPublic || t.IsNestedPublic)
|
||||
.SelectMany(t => t.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
.Where(m => (m is PropertyInfo || m is FieldInfo) && m.Name == "Status")
|
||||
.ToList();
|
||||
|
||||
Assert.True(offendingMembers.Count == 0,
|
||||
$"Found public member(s) named exactly 'Status' in New.Domain: {string.Join(", ", offendingMembers.Select(m => $"{m.DeclaringType!.Name}.{m.Name}"))}. " +
|
||||
"Use ProcessStatus (case-framework-sourced) or AssessmentOutcome (domain decision) instead.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule7_ApplicationSourceResolver_Is_Only_Type_Referencing_Both_Sources()
|
||||
{
|
||||
// ApplicationSourceResolver is `internal` (New.Api) with no
|
||||
// InternalsVisibleTo grant, so it can't be named via `typeof` here -
|
||||
// looked up by name instead, exactly as NetArchTest itself inspects
|
||||
// compiled IL rather than relying on compile-time visibility.
|
||||
var resolverType = ApiAssembly.GetType("New.Api.Resolution.ApplicationSourceResolver");
|
||||
Assert.NotNull(resolverType);
|
||||
|
||||
var ownedType = typeof(OwnedApplicationSource);
|
||||
var legacyType = typeof(LegacyCaseSource);
|
||||
|
||||
var typesReferencingBoth = AllNewAssemblies
|
||||
.SelectMany(GetLoadableTypes)
|
||||
.Where(t => ReferencesType(t, ownedType) && ReferencesType(t, legacyType))
|
||||
.ToList();
|
||||
|
||||
Assert.True(
|
||||
typesReferencingBoth.Count == 1 && typesReferencingBoth[0] == resolverType,
|
||||
$"Expected only {resolverType!.Name} to reference both {nameof(OwnedApplicationSource)} and {nameof(LegacyCaseSource)}, " +
|
||||
$"but found: {string.Join(", ", typesReferencingBoth.Select(t => t.FullName))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule8_TakeOwnershipHandler_References_Only_Ports()
|
||||
{
|
||||
var result = Types.InAssembly(ApplicationAssembly)
|
||||
.That().HaveName(nameof(TakeOwnershipHandler))
|
||||
.Should().NotHaveDependencyOnAny(
|
||||
"New.Infrastructure.Persistence", "New.Infrastructure.Legacy", "New.Infrastructure.CaseFramework")
|
||||
.GetResult();
|
||||
Assert.True(result.IsSuccessful, Describe(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule9_No_New_Project_References_SqlServer()
|
||||
{
|
||||
var offending = AllNewAssemblies
|
||||
.Where(a => a.GetReferencedAssemblies().Any(r => r.Name == "Microsoft.EntityFrameworkCore.SqlServer"))
|
||||
.ToList();
|
||||
|
||||
Assert.True(offending.Count == 0,
|
||||
$"These New.* assemblies reference Microsoft.EntityFrameworkCore.SqlServer: {string.Join(", ", offending.Select(a => a.GetName().Name))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule10_Legacy_SqlServer_Only_Is_The_Legacy_Agents_Concern()
|
||||
{
|
||||
// Owned by legacy/ (a separate solution) - not referenceable from
|
||||
// this test project. Verified by inspection there instead.
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rule11_Api_Never_Both_Constructs_Legacy_Dto_And_Touches_DbContext()
|
||||
{
|
||||
// Structurally all-but-guaranteed already: legacy DTOs are internal to
|
||||
// New.Infrastructure.Legacy with no InternalsVisibleTo grant (rule 3),
|
||||
// so New.Api cannot even name them, let alone construct one. This is
|
||||
// therefore a best-effort namespace-level check, not full proof - see
|
||||
// ADR-002 for why rule 11's stronger claim ("the write-through
|
||||
// translator contains no branching on request values") is a review
|
||||
// rule, not a machine-enforced one.
|
||||
var apiTypesTouchingDbContext = Types.InAssembly(ApiAssembly)
|
||||
.That().HaveDependencyOn("New.Infrastructure.Persistence")
|
||||
.GetTypes();
|
||||
|
||||
var apiTypesTouchingLegacyDtos = Types.InAssembly(ApiAssembly)
|
||||
.That().HaveDependencyOn("New.Infrastructure.Legacy.Dtos")
|
||||
.GetTypes();
|
||||
|
||||
var overlap = apiTypesTouchingDbContext.Intersect(apiTypesTouchingLegacyDtos).ToList();
|
||||
|
||||
Assert.True(overlap.Count == 0,
|
||||
$"These New.Api types both touch persistence and legacy DTOs: {string.Join(", ", overlap.Select(t => t.FullName))}");
|
||||
}
|
||||
|
||||
private static bool ReferencesType(Type t, Type target)
|
||||
{
|
||||
if (t == target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
|
||||
var ctorParamTypes = t.GetConstructors(all).SelectMany(c => c.GetParameters()).Select(p => p.ParameterType);
|
||||
var fieldTypes = t.GetFields(all).Select(f => f.FieldType);
|
||||
var propTypes = t.GetProperties(all).Select(p => p.PropertyType);
|
||||
|
||||
return ctorParamTypes.Concat(fieldTypes).Concat(propTypes).Any(x => x == target);
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(t => t is not null)!;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(TestResult result) =>
|
||||
result.IsSuccessful ? string.Empty : $"Failing types: {string.Join(", ", result.FailingTypes?.Select(t => t.FullName) ?? [])}";
|
||||
}
|
||||
Reference in New Issue
Block a user