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:
eho
2026-07-31 07:57:26 +02:00
co-authored by Claude Sonnet 5
parent 09b27173a7
commit a6a1abbe9c
129 changed files with 6379 additions and 1 deletions
-1
View File
@@ -1 +0,0 @@
{"sessionId":"242e77f6-a2f0-4051-97fa-f360cc57a6ce","pid":386013,"procStart":"7345046","acquiredAt":1785426330459}
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
node_modules/
dist/
.vs/
*.user
.env
+135
View File
@@ -0,0 +1,135 @@
# Strangler seam demo: Behandel portaal
A reference demo, not a product. It makes four integration seams and three
write paths between a legacy system and its replacement runnable, so a
migration strategy can be watched instead of slide-decked.
## Run it
```
docker compose up -d --build
```
Then open **http://localhost:8080**. That's the only host port published —
`legacy-backend` and `case-framework` are deliberately unreachable from the
host (see §4 below).
**Memory:** SQL Server (`legacy-db`) needs roughly 2GB of RAM; budget ~6GB
total for Docker/Podman. First start takes a minute or two while SQL Server
initialises (the healthcheck has a 60s `start_period`) — the app containers
wait on it before running their own migrations and seed data.
Verify everything end to end:
```
./scripts/smoke.sh
```
Run this against a **freshly started** stack — it depends on the untouched
seed data (legacy ids 10011012, owned ids `REG-2026-0001..0005`).
## What's built so far (Session 1 — backend)
Nine containers: two frontends (one placeholder, see below), three backends,
three databases, one proxy. `new-frontend` in this session is a deliberately
plain, unstyled HTML page (`new-frontend/index.html`) that exercises the same
API a real UI would — it exists to prove the backend before a real Angular
portal replaces it in Session 2, not to be a good UI.
## The seams and write paths
| Seam / write path | Direction | Implementation | Proven by |
|---|---|---|---|
| **A — Read ACL** | new backend → legacy API | `new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs`, `LegacyWorklistReader.cs` | `GET /api/worklist` returns 17 merged items |
| **B — Write-through ACL** | new backend → legacy API | `new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs` | valid edit persists to `legacy-db`; a 3-field-invalid payload returns 3 mapped field errors |
| **C — Redirect** | new frontend → legacy frontend | `CaseDetailResponseFactory.BuildLegacyActions` (`new/src/New.Api/Contracts/CaseDetailResponseFactory.cs`) | a legacy case's `recordAssessment` action has `mode: "redirect"` |
| **D — Conformist** | new backend → case framework | `new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs` | `case-framework`'s 409-on-open-task rule surfaces as `closurePending` on assessment |
| **Redirect** write path | legacy enforces | `legacy/src/Legacy.Web/Pages/Beoordeling.cshtml` | outbound button, not a form |
| **Write-through** write path | legacy enforces | `PUT /api/worklist/legacy/{id}/details` | `Gevalideerd door het legacy systeem`-equivalent: every legacy error surfaces, none invented |
| **Owned** write path | new domain enforces | `New.Application.Assessments.RecordOwnedAssessmentHandler`, `UpdateOwnedApplicantDetailsHandler` | direct invalid payload to the assessment endpoint returns 422 |
| **Take ownership** | the strangler step | `New.Application.Ownership.TakeOwnershipHandler` | `POST /api/worklist/legacy/{id}/take-ownership` flips the resolver, seam inspector, and legacy's `MIGRATED` flag together |
| **Release ownership** | reversal | `New.Application.Ownership.ReleaseOwnershipHandler` | `204` with no edits, `409` once `domain_writes_since > 0` |
The single component that knows both sources exist is
`New.Api.Resolution.ApplicationSourceResolver` — enforced by
`Architecture.Tests` (rule 7), along with 10 other rules (project-reference
direction, no bare `Status` in the domain, no SQL Server package reference
anywhere under `new/`, ...). Run them with:
```
cd new && dotnet test tests/Architecture.Tests
```
## Why two database engines
`legacy-db` is SQL Server 2022; `new-db` and `case-db` are PostgreSQL 16.
This isn't decoration — a single shared engine would let an implementer
quietly join across schemas or share a `DbContext`, and the seam would
evaporate. Two engines force the read ACL to be a real HTTP call (§7.2),
force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
constraint rather than a stylistic choice (no distributed transaction is
available across them), and make the legacy type vocabulary
(`CHAR`/`BIT`/`DATETIME2`, space-padded BSNs, local-time timestamps) into real
work for `LegacyAanvraagMapper` instead of a copy-paste.
## Deliberate substitutions and omissions
- **Legacy.Web (Razor Pages) stands in for WinUI.** WinUI can't be
containerised; a server-rendered, table-heavy, deliberately dated UI reads
as "legacy" just as effectively.
- **No auth.** Out of scope for the whole demo — see `docs/adr/` for what
*is* in scope.
- **No data sync between the two databases** — documented, not built. See
`docs/sync-not-implemented.md` for its two visible consequences (adopted
legacy rows show as stale-and-locked, and ownership release is blocked once
edits exist).
- **No bulk migration tooling.** Ownership is taken one legacy case at a
time, as an interim mechanism — see `docs/adr/ADR-003-ownership-is-taken-per-case.md`
for why, and for the intended path to a future bulk cutover for processes
that want one.
## Architecture Decision Records
- [`ADR-001`](docs/adr/ADR-001-decision-independent-of-closure.md) — a
register decision takes effect independently of case-framework closure.
- [`ADR-002`](docs/adr/ADR-002-write-through-has-no-business-rules.md) — the
write-through translator carries no business rules.
- [`ADR-003`](docs/adr/ADR-003-ownership-is-taken-per-case.md) — ownership is
taken per-case for now; bulk migration is a planned, separate capability.
- [`sync-not-implemented.md`](docs/sync-not-implemented.md).
## 10-minute click-through
1. **Werkvoorraad**`GET /api/worklist` (or the placeholder page at `/`):
17 cases from two databases in one list. Filter `?origin=Legacy` /
`?origin=Owned` to see which is which.
2. **`A-1001`** (`GET /api/worklist/legacy/1001`) — all three write paths
visible in one `actions` block; the `seams` block names where each
section's data comes from.
3. **`Gegevens wijzigen` with a bad payload** — `PUT
/api/worklist/legacy/1001/details` with a blank surname, missing house
number, and malformed postcode returns three field-level errors, one per
input.
4. **`Beoordeling`** on a legacy case — `actions.recordAssessment.mode ==
"redirect"`; following it lands on `/legacy/aanvraag/1001/beoordeling`,
outside the new portal.
5. **`A-1002` — take ownership.** `POST
/api/worklist/legacy/1002/take-ownership` → `201`. Re-fetch the same case
by its new id: the `seams` block now reads `owned` throughout, the
redirect and write-through actions are gone, replaced by owned-mode
actions. This is the argument the whole demo is making — same screen,
same two actions, only the authority changed.
6. **`/legacy`** — row 1002 now renders greyed out with a
`beheerd in nieuw portaal` link back into the new portal.
7. **`A-1005`** — `POST /api/worklist/legacy/1005/take-ownership` → `422`,
naming `Bsn.ElevenProof`. Three more distinct adoption failures exist at
`1003` (contact), `1006` (motivation), `1007` (partial address) — one
failure looks like a bug, four look like a policy.
8. **`REG-2026-0002`** — `POST
/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment`
succeeds and reports `closurePending: true`; the case-framework's own
closure-request is genuinely conflicted (an open task), and the decision
stands regardless.
Step 5 is the argument; everything before it is setup, everything after is
evidence that the boundaries hold.
+13
View File
@@ -0,0 +1,13 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY src/CaseFramework.Api/ src/CaseFramework.Api/
# restore+publish combined in one RUN/layer - see legacy/src/Legacy.Api/Dockerfile for why.
RUN dotnet restore src/CaseFramework.Api/CaseFramework.Api.csproj && \
dotnet publish src/CaseFramework.Api/CaseFramework.Api.csproj -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "CaseFramework.Api.dll"]
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
namespace CaseFramework.Api;
public class CaseDbContext(DbContextOptions<CaseDbContext> options) : DbContext(options)
{
public DbSet<CaseEntity> Cases => Set<CaseEntity>();
public DbSet<CaseTaskEntity> Tasks => Set<CaseTaskEntity>();
public DbSet<TimelineEntryEntity> TimelineEntries => Set<TimelineEntryEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CaseEntity>(entity =>
{
entity.HasKey(c => c.Id);
// Npgsql maps List<string> to a native Postgres text[] column.
entity.Property(c => c.Participants).HasColumnType("text[]");
entity.HasMany(c => c.Tasks)
.WithOne()
.HasForeignKey(t => t.CaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(c => c.TimelineEntries)
.WithOne()
.HasForeignKey(t => t.CaseId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<CaseTaskEntity>(entity =>
{
entity.HasKey(t => t.Id);
});
modelBuilder.Entity<TimelineEntryEntity>(entity =>
{
entity.HasKey(t => t.Id);
});
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
</Project>
@@ -0,0 +1,52 @@
namespace CaseFramework.Api;
/// <summary>
/// A case managed by the framework. This is the framework's own aggregate;
/// callers reference it by <see cref="Id"/> and may attach their own
/// <see cref="ExternalReference"/> for correlation.
/// </summary>
public class CaseEntity
{
public Guid Id { get; set; }
public string CaseTypeCode { get; set; } = string.Empty;
public string ExternalReference { get; set; } = string.Empty;
public string ProcessStatus { get; set; } = string.Empty;
public List<string> Participants { get; set; } = new();
public List<CaseTaskEntity> Tasks { get; set; } = new();
public List<TimelineEntryEntity> TimelineEntries { get; set; } = new();
}
/// <summary>A unit of work that must be completed before a case may be closed.</summary>
public class CaseTaskEntity
{
public Guid Id { get; set; }
public Guid CaseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public bool IsOpen { get; set; } = true;
}
/// <summary>An immutable audit entry appended to a case's history.</summary>
public class TimelineEntryEntity
{
public Guid Id { get; set; }
public Guid CaseId { get; set; }
public DateTimeOffset At { get; set; }
public string Kind { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}
public static class ProcessStatus
{
public const string InBehandeling = "InBehandeling";
public const string Afgesloten = "Afgesloten";
}
public static class TimelineKind
{
public const string CaseCreated = "CaseCreated";
public const string TaskOpened = "TaskOpened";
public const string TaskCompleted = "TaskCompleted";
public const string CaseClosed = "CaseClosed";
}
@@ -0,0 +1,186 @@
using CaseFramework.Api;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CaseDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("CaseFramework")));
var app = builder.Build();
// Self-provision schema against an empty Postgres database on first boot.
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<CaseDbContext>();
await dbContext.Database.EnsureCreatedAsync();
}
app.MapPost("/cases", async (CreateCaseRequest request, CaseDbContext db) =>
{
var now = DateTimeOffset.UtcNow;
var caseEntity = new CaseEntity
{
Id = Guid.NewGuid(),
CaseTypeCode = request.CaseTypeCode,
ExternalReference = request.ExternalReference,
ProcessStatus = ProcessStatus.InBehandeling,
Participants = request.Participants?.ToList() ?? new List<string>(),
};
caseEntity.TimelineEntries.Add(new TimelineEntryEntity
{
Id = Guid.NewGuid(),
CaseId = caseEntity.Id,
At = now,
Kind = TimelineKind.CaseCreated,
Description = $"Case created with type '{caseEntity.CaseTypeCode}'.",
});
db.Cases.Add(caseEntity);
await db.SaveChangesAsync();
return Results.Ok(new CreateCaseResponse(caseEntity.Id, caseEntity.ProcessStatus));
});
app.MapGet("/cases/{id:guid}", async (Guid id, CaseDbContext db) =>
{
var caseEntity = await db.Cases.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id);
if (caseEntity is null)
{
return Results.NotFound();
}
return Results.Ok(new CaseResponse(
caseEntity.Id,
caseEntity.CaseTypeCode,
caseEntity.ExternalReference,
caseEntity.ProcessStatus,
caseEntity.Participants));
});
app.MapGet("/cases/{id:guid}/timeline", async (Guid id, CaseDbContext db) =>
{
var caseExists = await db.Cases.AnyAsync(c => c.Id == id);
if (!caseExists)
{
return Results.NotFound();
}
var entries = await db.TimelineEntries
.AsNoTracking()
.Where(t => t.CaseId == id)
.OrderBy(t => t.At)
.Select(t => new TimelineEntryResponse(t.At, t.Kind, t.Description))
.ToListAsync();
return Results.Ok(new TimelineResponse(entries));
});
app.MapPost("/cases/{id:guid}/tasks", async (Guid id, CreateTaskRequest request, CaseDbContext db) =>
{
var caseEntity = await db.Cases.FirstOrDefaultAsync(c => c.Id == id);
if (caseEntity is null)
{
return Results.NotFound();
}
var task = new CaseTaskEntity
{
Id = Guid.NewGuid(),
CaseId = id,
Code = request.Code,
Description = request.Description,
IsOpen = true,
};
db.Tasks.Add(task);
db.TimelineEntries.Add(new TimelineEntryEntity
{
Id = Guid.NewGuid(),
CaseId = id,
At = DateTimeOffset.UtcNow,
Kind = TimelineKind.TaskOpened,
Description = $"Task '{task.Code}' opened: {task.Description}",
});
await db.SaveChangesAsync();
return Results.Ok(new CreateTaskResponse(task.Id, task.IsOpen));
});
app.MapPost("/cases/{id:guid}/tasks/{taskId:guid}/complete", async (Guid id, Guid taskId, CaseDbContext db) =>
{
var caseExists = await db.Cases.AnyAsync(c => c.Id == id);
if (!caseExists)
{
return Results.NotFound();
}
var task = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId && t.CaseId == id);
if (task is null)
{
return Results.NotFound();
}
task.IsOpen = false;
db.TimelineEntries.Add(new TimelineEntryEntity
{
Id = Guid.NewGuid(),
CaseId = id,
At = DateTimeOffset.UtcNow,
Kind = TimelineKind.TaskCompleted,
Description = $"Task '{task.Code}' completed.",
});
await db.SaveChangesAsync();
return Results.NoContent();
});
app.MapPost("/cases/{id:guid}/closure-request", async (Guid id, CaseDbContext db) =>
{
var caseEntity = await db.Cases.FirstOrDefaultAsync(c => c.Id == id);
if (caseEntity is null)
{
return Results.NotFound();
}
var hasOpenTasks = await db.Tasks.AnyAsync(t => t.CaseId == id && t.IsOpen);
if (hasOpenTasks)
{
return Results.Conflict();
}
caseEntity.ProcessStatus = ProcessStatus.Afgesloten;
db.TimelineEntries.Add(new TimelineEntryEntity
{
Id = Guid.NewGuid(),
CaseId = id,
At = DateTimeOffset.UtcNow,
Kind = TimelineKind.CaseClosed,
Description = "Case closed.",
});
await db.SaveChangesAsync();
return Results.NoContent();
});
app.Run();
internal record CreateCaseRequest(string CaseTypeCode, string ExternalReference, List<string>? Participants);
internal record CreateCaseResponse(Guid Id, string ProcessStatus);
internal record CaseResponse(
Guid Id,
string CaseTypeCode,
string ExternalReference,
string ProcessStatus,
List<string> Participants);
internal record CreateTaskRequest(string Code, string Description);
internal record CreateTaskResponse(Guid TaskId, bool Open);
internal record TimelineEntryResponse(DateTimeOffset At, string Kind, string Description);
internal record TimelineResponse(List<TimelineEntryResponse> Entries);
+95
View File
@@ -0,0 +1,95 @@
services:
legacy-db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:-P@ssw0rd_Demo123}
MSSQL_PID: Developer
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -Q 'SELECT 1' || exit 1"]
interval: 10s
retries: 10
start_period: 60s
new-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: newdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
start_period: 10s
case-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: caseframeworkdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
start_period: 10s
case-framework:
build: ./case-framework
environment:
ConnectionStrings__CaseFramework: Host=case-db;Port=5432;Database=caseframeworkdb;Username=postgres;Password=postgres
ASPNETCORE_URLS: http://+:8080
depends_on:
case-db:
condition: service_healthy
legacy-backend:
build:
context: ./legacy
dockerfile: src/Legacy.Api/Dockerfile
environment:
ConnectionStrings__Legacy: Server=legacy-db,1433;Database=legacydb;User Id=sa;Password=${MSSQL_SA_PASSWORD:-P@ssw0rd_Demo123};TrustServerCertificate=True
ASPNETCORE_URLS: http://+:8080
depends_on:
legacy-db:
condition: service_healthy
legacy-frontend:
build:
context: ./legacy
dockerfile: src/Legacy.Web/Dockerfile
environment:
Services__LegacyApi__BaseUrl: http://legacy-backend:8080
ASPNETCORE_URLS: http://+:8080
depends_on:
- legacy-backend
new-backend:
build: ./new
environment:
ConnectionStrings__New: Host=new-db;Port=5432;Database=newdb;Username=postgres;Password=postgres
Services__LegacyBackend__BaseUrl: http://legacy-backend:8080
Services__CaseFramework__BaseUrl: http://case-framework:8080
ASPNETCORE_URLS: http://+:8080
depends_on:
new-db:
condition: service_healthy
legacy-backend:
condition: service_started
case-framework:
condition: service_started
new-frontend:
build: ./new-frontend
proxy:
image: nginx:alpine
volumes:
- ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro,Z
ports:
- "8080:80"
depends_on:
- new-frontend
- new-backend
- legacy-frontend
@@ -0,0 +1,49 @@
# ADR-001: A register decision takes effect independently of case closure
## Status
Accepted.
## Context
`case-framework` (seam D, a stand-in for a maintained vendor case-management
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
while any task on the case is still open. That rule belongs to the framework
and is not ours to change — it is a conformist integration by design (§6).
The new domain's own rule is different: once an assessment (approve/reject) is
recorded on a `RegistrationApplication`, that decision is legally in effect
immediately. It cannot wait for an administrative task (e.g. a filing or
notification step) to be ticked off in a separate system.
These two rules can genuinely conflict: an assessment can be recorded while a
case-framework task is still open, at which point the case cannot yet be
closed.
## Decision
Recording an assessment and requesting case closure are treated as two
separate, non-transactional steps:
1. `POST /api/worklist/owned/{id}/assessment` records the decision on the
aggregate and commits it. This always succeeds if the domain invariants are
satisfied, regardless of case-framework's task state.
2. The handler then calls `POST /cases/{id}/closure-request` on seam D as a
best-effort follow-up. A `409` here is an **expected, non-exceptional**
outcome, not a failure: the assessment is not rolled back, and the response
reports `closurePending: true` instead of an error.
The user-facing consequence: the outcome is decided immediately, with the UI
showing `Besluit vastgelegd. Administratieve afsluiting in afwachting.` when
closure is still pending. Administrative closure catches up whenever the
remaining task is completed — a scenario this demo does not automate, since it
is not a claim about the framework, only proof that it can lag safely.
## Consequences
- The domain layer's assessment-recording method must not be coupled to
case-framework's closure semantics — it has none of that knowledge, by
design (New.Domain/New.Application never reference the case-framework
client, see Architecture.Tests rules 1 and 8).
- A case can sit in "decided but not administratively closed" indefinitely.
That is accepted, not a bug: it is the visible cost of a conformist
integration whose task-completion timing this system does not control.
- No compensating transaction exists for a closure-request failure, because
there is nothing to compensate — the assessment was correct and complete on
its own terms.
@@ -0,0 +1,50 @@
# ADR-002: The write-through translator carries no business rules
## Status
Accepted.
## Context
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
address, contact) from the new portal, without the new system taking
ownership of that case. The legacy system remains the authority on this data
until ownership is explicitly taken (§7.5).
It is tempting, once a translation layer exists between the portal's request
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
in a validation shortcut or two — "just check the postcode format here too, it
saves a round trip." That temptation is exactly what this ADR forecloses.
## Decision
`New.Infrastructure.Legacy`'s write-through translator (the type backing
`ILegacyCaseGateway.UpdateDetailsAsync`) contains **no business rules**: no
conditionals on request values, no validation beyond null/shape checks, no
derived values, no defaulting. It only:
1. Maps the portal's 9-field request onto legacy's expected shape.
2. Calls `PUT legacy-backend/api/aanvragen/{id}/gegevens`.
3. Maps legacy's response — success, or **every** returned field error via the
`veld`/`code` → portal-field-path table — back into the portal's error
shape, including a generic fallback for any unrecognized legacy code
(logged as a warning, never dropped or guessed at).
If a rule needs to be enforced on this data from the new portal, that is a
signal the capability should be taken into ownership instead (§7.5), not
patched into the translator.
## Consequences
- The portal cannot offer a better validation experience than legacy already
has for this seam — by design. The `Gevalideerd door het legacy systeem`
notice on the write-through form (§8.3) exists specifically so the user
knows why: this is the honest version of a seamless UI, not a limitation to
hide.
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
request DTO and touches a `DbContext`) is only a **partial**, structural
proxy for this constraint — and is already close to vacuous given rule 3
(legacy DTOs are `internal` to `New.Infrastructure.Legacy` with no
`InternalsVisibleTo` grant, so `New.Api` cannot even name them). The
stronger claim this ADR makes — that the translator itself contains no
conditional business logic — is a **code-review rule**, not a
machine-enforced one. We say so here rather than implying test coverage
that does not exist.
- Any future temptation to "just add one small check" in the translator
should instead be read as a signal to take that capability into ownership.
@@ -0,0 +1,59 @@
# ADR-003: Ownership is taken per case for now — bulk migration is a later, separate capability
## Status
Accepted (interim). Superseded in part once bulk migration tooling (see
"Future work" below) ships.
## Context
The end state for at least some processes — registration cases among them —
is a **bulk cutover**: migrate the whole remaining population in one
operation and retire the legacy path for that process on a clean date. That
is a real, wanted outcome, not something this design argues against.
What this system cannot do is wait for that bulk-migration tooling to exist
before shipping anything of business value. Building a safe bulk migration
requires solving problems this demo deliberately defers: what happens to rows
that fail adoption (four such failure modes already exist in the seed data —
contact, BSN, motivation, and partial-address invariant violations), how a
partially-failed batch is reported and retried, and how the cutover is
scheduled and communicated. None of that should block getting the read ACL,
write-through ACL, and take-ownership mechanics themselves live and earning
their keep.
## Decision
Ship now with ownership taken **one legacy case at a time**, via
`POST /api/worklist/legacy/{aanvraagId}/take-ownership` (§7.5), triggered by
an explicit user action in the portal. This is the interim mechanism, not the
final one for every process.
This is deliberately the right building block either way:
- It is the same adoption logic (mapping, invariant validation, case-framework
correlation, atomic persistence, migratie-vlag flip) that a future bulk tool
would need to call in a loop — building it per-case first means the bulk
tool is an orchestration layer on top of already-proven logic, not a
parallel implementation to keep in sync.
- It gives a real, visible answer today for what a bulk migration would
otherwise discover the hard way: which legacy rows fail adoption and why
(surfaced here as a named `422` per case, not a batch-job log line).
- The read ACL (seam A) and write-through ACL (seam B) must work correctly
for a partially-adopted population regardless of how adoption happens —
that requirement doesn't change once bulk tooling exists.
## Consequences
- Until bulk tooling exists, full legacy retirement for a process happens
case-by-case, which is slower than a scheduled cutover — accepted as the
cost of shipping the seam mechanics now rather than waiting.
- Reversal (§7.6) stays per-case and gated on `domain_writes_since` for the
same reason a bulk reversal would be unsafe absent a sync
(`docs/sync-not-implemented.md`): undoing adoption after edits would
silently discard them.
- This demo's non-goals (§3) exclude building the bulk migration tool itself
— that's future work, not a rejected idea.
## Future work
A bulk migration tool for a given process (e.g. registration cases) can reuse
the same take-ownership handler per legacy id, adding: pre-flight reporting of
which rows would fail adoption and why (so the four invariant-failure classes
seen here are triaged before cutover, not discovered during it), a scheduled
cutover window, and a decision on whether failed rows block the cutover or are
carved out and finished by hand.
+26
View File
@@ -0,0 +1,26 @@
# Sync: documented, not implemented
In production, a one-way sync would propagate data the new system owns back
to the legacy store, so legacy-side readers (reports, other integrations that
still query `legacy-db` directly) keep seeing current data for adopted cases.
- **Direction:** new → old only. Never the reverse — once a case is owned,
the new domain is the sole authority on it (ADR-003), so nothing should flow
back to overwrite the new aggregate.
- **Shrinks over time:** as more capabilities are taken into ownership (and,
eventually, as legacy readers are themselves retired or redirected), the
set of fields this sync needs to cover shrinks. It does not grow.
This demo deliberately does **not** implement it. Its absence has two visible
consequences, both intentional:
1. **The legacy UI shows adopted cases as stale-and-locked, not updated.**
`/legacy` renders a `migrated=true` row greyed out with actions disabled
and a link back to the new portal — it does not show the new system's
edits, because nothing pushes them there. That greyed-out treatment is the
honest substitute for a sync that does not exist.
2. **Ownership release is blocked once edits exist.** `DELETE
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
> 0` (§7.6) — releasing would silently discard those edits, since there is
no sync to have propagated them back to legacy first. The `409` is the cost
of the missing sync made visible, rather than a data-loss bug made invisible.
+32
View File
@@ -0,0 +1,32 @@
namespace Legacy.Api.Data;
/// <summary>
/// Maps to dbo.AANVR. Property names deliberately mirror the legacy column
/// vocabulary (abbreviated Dutch) rather than modern domain terms - that
/// translation is the downstream anti-corruption layer's job, not ours.
/// </summary>
public class Aanvraag
{
public int Id { get; set; }
public string Bsn { get; set; } = "";
public string Naam { get; set; } = "";
public string? Voorl { get; set; }
public string? AdresStr { get; set; }
public string? AdresNr { get; set; }
public string? AdresPc { get; set; }
public string? AdresPl { get; set; }
public string? Email { get; set; }
public string? Telnr { get; set; }
public string CorrKanaal { get; set; } = "P";
public string StatCd { get; set; } = "O";
public string? DiplCd { get; set; }
public string? DiplLand { get; set; }
public DateOnly? DiplDat { get; set; }
public DateOnly DatOntv { get; set; }
public DateOnly? DatBeoord { get; set; }
public string? BeoordRes { get; set; }
public string? BeoordMotiv { get; set; }
public bool Migrated { get; set; }
public DateTime MutDat { get; set; }
public string MutUser { get; set; } = "seed";
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
namespace Legacy.Api.Data;
public class LegacyDbContext(DbContextOptions<LegacyDbContext> options) : DbContext(options)
{
public DbSet<Aanvraag> Aanvragen => Set<Aanvraag>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Aanvraag>(e =>
{
e.ToTable("AANVR", "dbo");
e.HasKey(a => a.Id);
e.Property(a => a.Id).HasColumnName("AANVR_ID").ValueGeneratedOnAdd();
e.Property(a => a.Bsn).HasColumnName("BSN").HasColumnType("char(9)").IsRequired();
e.Property(a => a.Naam).HasColumnName("NAAM").HasMaxLength(60).IsRequired();
e.Property(a => a.Voorl).HasColumnName("VOORL").HasMaxLength(10);
e.Property(a => a.AdresStr).HasColumnName("ADRES_STR").HasMaxLength(80);
e.Property(a => a.AdresNr).HasColumnName("ADRES_NR").HasMaxLength(10);
e.Property(a => a.AdresPc).HasColumnName("ADRES_PC").HasColumnType("char(6)");
e.Property(a => a.AdresPl).HasColumnName("ADRES_PL").HasMaxLength(60);
e.Property(a => a.Email).HasColumnName("EMAIL").HasMaxLength(120);
e.Property(a => a.Telnr).HasColumnName("TELNR").HasMaxLength(20);
e.Property(a => a.CorrKanaal).HasColumnName("CORR_KANAAL").HasColumnType("char(1)")
.HasDefaultValue("P").IsRequired();
e.Property(a => a.StatCd).HasColumnName("STAT_CD").HasColumnType("char(1)").IsRequired();
e.Property(a => a.DiplCd).HasColumnName("DIPL_CD").HasMaxLength(10);
e.Property(a => a.DiplLand).HasColumnName("DIPL_LAND").HasColumnType("char(2)");
e.Property(a => a.DiplDat).HasColumnName("DIPL_DAT").HasColumnType("date");
e.Property(a => a.DatOntv).HasColumnName("DAT_ONTV").HasColumnType("date").IsRequired();
e.Property(a => a.DatBeoord).HasColumnName("DAT_BEOORD").HasColumnType("date");
e.Property(a => a.BeoordRes).HasColumnName("BEOORD_RES").HasColumnType("char(1)");
e.Property(a => a.BeoordMotiv).HasColumnName("BEOORD_MOTIV").HasMaxLength(500);
e.Property(a => a.Migrated).HasColumnName("MIGRATED").HasDefaultValue(false).IsRequired();
e.Property(a => a.MutDat).HasColumnName("MUT_DAT").HasColumnType("datetime2").IsRequired();
e.Property(a => a.MutUser).HasColumnName("MUT_USER").HasMaxLength(30).IsRequired();
});
}
}
+144
View File
@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
namespace Legacy.Api.Data;
/// <summary>
/// Idempotent seed of the 12 demo AANVR rows at ids 1001-1012. Safe to run on
/// every startup: it only inserts when the table is empty.
/// </summary>
public static class LegacySeeder
{
public static async Task SeedAsync(LegacyDbContext db)
{
if (await db.Aanvragen.AnyAsync())
{
return;
}
var rows = new List<Aanvraag>
{
new()
{
Id = 1001, Bsn = "195751814", Naam = "de Vries", Voorl = "A.",
AdresStr = "Kerkweg", AdresNr = "12", AdresPc = "3512JK", AdresPl = "Utrecht",
Email = "anna.devries@example.nl", Telnr = "+31 6 12345678", CorrKanaal = "P",
StatCd = "O", DiplCd = "WO-ECO", DiplLand = "DE", DiplDat = new DateOnly(2015, 6, 20),
DatOntv = new DateOnly(2026, 3, 10),
MutDat = new DateTime(2026, 3, 10, 9, 15, 0), MutUser = "seed",
},
new()
{
Id = 1002, Bsn = "254488808", Naam = "Jansen", Voorl = "P.",
AdresStr = "Prinsengracht", AdresNr = "45", AdresPc = "1016HB", AdresPl = "Amsterdam",
Email = "piet.jansen@example.nl", Telnr = "020 1234567", CorrKanaal = "P",
StatCd = "B", DiplCd = "HBO-VPK", DiplLand = "BE", DiplDat = new DateOnly(2012, 7, 1),
DatOntv = new DateOnly(2025, 11, 20), DatBeoord = new DateOnly(2025, 12, 5),
BeoordRes = "G",
BeoordMotiv = "Aanvraag voldoet aan alle diploma-eisen en documentatie is compleet.",
MutDat = new DateTime(2025, 12, 5, 11, 0, 0), MutUser = "seed",
},
new()
{
Id = 1003, Bsn = "862102455", Naam = "El Amrani", Voorl = "F.",
AdresStr = "Molenstraat", AdresNr = "8", AdresPc = "5611EM", AdresPl = "Eindhoven",
Email = null, Telnr = "+31 6 87654321", CorrKanaal = "E",
StatCd = "O", DiplCd = "WO-ING", DiplLand = "MA", DiplDat = new DateOnly(2018, 6, 15),
DatOntv = new DateOnly(2026, 5, 2),
MutDat = new DateTime(2026, 5, 2, 8, 45, 0), MutUser = "seed",
},
new()
{
Id = 1004, Bsn = "501061964", Naam = "Bakker", Voorl = "L.",
AdresStr = "Nieuwstraat", AdresNr = "22", AdresPc = "4811XB", AdresPl = "Breda",
Email = "lisa.bakker@example.nl", Telnr = "076 5432109", CorrKanaal = "P",
StatCd = "X", DiplCd = "HBO-ICT", DiplLand = "GB", DiplDat = new DateOnly(2010, 5, 10),
DatOntv = new DateOnly(2025, 8, 14),
MutDat = new DateTime(2025, 8, 20, 14, 30, 0), MutUser = "seed",
},
new()
{
Id = 1005, Bsn = "000000000", Naam = "Visser", Voorl = "J.",
AdresStr = "Hoofdstraat", AdresNr = "3", AdresPc = "9711AA", AdresPl = "Groningen",
Email = "jan.visser@example.nl", Telnr = "+31 6 11223344", CorrKanaal = "P",
StatCd = "O", DiplCd = "MBO-ZORG", DiplLand = "PL", DiplDat = new DateOnly(2019, 9, 1),
DatOntv = new DateOnly(2026, 2, 18),
MutDat = new DateTime(2026, 2, 18, 10, 5, 0), MutUser = "seed",
},
new()
{
Id = 1006, Bsn = "184513418", Naam = "Okonkwo", Voorl = "C.",
AdresStr = "Zuidplein", AdresNr = "14", AdresPc = "3083CN", AdresPl = "Rotterdam",
Email = "c.okonkwo@example.nl", Telnr = "+31 6 22334455", CorrKanaal = "P",
StatCd = "B", DiplCd = "WO-GEN", DiplLand = "NG", DiplDat = new DateOnly(2016, 7, 1),
DatOntv = new DateOnly(2025, 10, 1), DatBeoord = new DateOnly(2025, 10, 15),
BeoordRes = "G", BeoordMotiv = "Akkoord",
MutDat = new DateTime(2025, 10, 15, 13, 20, 0), MutUser = "seed",
},
new()
{
Id = 1007, Bsn = "682298268", Naam = "Smit", Voorl = "R.",
AdresStr = "Kerkstraat", AdresNr = null, AdresPc = "2611GA", AdresPl = "Delft",
Email = "r.smit@example.nl", Telnr = "+31 6 33445566", CorrKanaal = "P",
StatCd = "O", DiplCd = "HBO-BWI", DiplLand = "TR", DiplDat = new DateOnly(2014, 6, 30),
DatOntv = new DateOnly(2026, 4, 22),
MutDat = new DateTime(2026, 4, 22, 15, 50, 0), MutUser = "seed",
},
new()
{
Id = 1008, Bsn = "794413821", Naam = "Vermeulen", Voorl = "M.",
AdresStr = "Julianastraat", AdresNr = "31", AdresPc = "6511PJ", AdresPl = "Nijmegen",
Email = "m.vermeulen@example.nl", Telnr = "+31 6 44556677", CorrKanaal = "P",
StatCd = "O", DiplCd = "WO-RECHT", DiplLand = "FR", DiplDat = new DateOnly(2013, 6, 25),
DatOntv = new DateOnly(2025, 9, 12),
MutDat = new DateTime(2025, 9, 12, 9, 0, 0), MutUser = "seed",
},
new()
{
Id = 1009, Bsn = "469486879", Naam = "Willems", Voorl = "S.",
AdresStr = "Grote Markt", AdresNr = "2", AdresPc = "2511BE", AdresPl = "Den Haag",
Email = "s.willems@example.nl", Telnr = "+31 6 55667788", CorrKanaal = "E",
StatCd = "B", DiplCd = "HBO-ECO", DiplLand = "ES", DiplDat = new DateOnly(2017, 7, 5),
DatOntv = new DateOnly(2025, 11, 3), DatBeoord = new DateOnly(2025, 11, 25),
BeoordRes = "G", BeoordMotiv = "Diploma is gewaardeerd conform de geldende richtlijnen.",
MutDat = new DateTime(2025, 11, 25, 16, 10, 0), MutUser = "seed",
},
new()
{
Id = 1010, Bsn = "349496213", Naam = "Peeters", Voorl = "K.",
AdresStr = "Stationsplein", AdresNr = "10", AdresPc = "5611AZ", AdresPl = "Eindhoven",
Email = "k.peeters@example.nl", Telnr = "040 1122334", CorrKanaal = "P",
StatCd = "A", DiplCd = "MBO-TECH", DiplLand = "IT", DiplDat = new DateOnly(2011, 6, 18),
DatOntv = new DateOnly(2025, 8, 20), DatBeoord = new DateOnly(2025, 9, 10),
BeoordRes = "G", BeoordMotiv = "Alle documenten zijn gecontroleerd en akkoord bevonden.",
MutDat = new DateTime(2025, 9, 10, 10, 40, 0), MutUser = "seed",
},
new()
{
Id = 1011, Bsn = "944293797", Naam = "Dekker", Voorl = "T.",
AdresStr = "Torenlaan", AdresNr = "18", AdresPc = "7511AB", AdresPl = "Enschede",
Email = null, Telnr = "+31 6 66778899", CorrKanaal = "E",
StatCd = "O", DiplCd = "WO-PSY", DiplLand = "PT", DiplDat = new DateOnly(2020, 6, 1),
DatOntv = new DateOnly(2026, 1, 15),
MutDat = new DateTime(2026, 1, 15, 12, 25, 0), MutUser = "seed",
},
new()
{
Id = 1012, Bsn = "380075131", Naam = "Mulder", Voorl = "H.",
AdresStr = "Beukenlaan", AdresNr = "4", AdresPc = "8011MN", AdresPl = "Zwolle",
Email = "h.mulder@example.nl", Telnr = "038 9988776", CorrKanaal = "P",
StatCd = "B", DiplCd = "HBO-EDU", DiplLand = "RO", DiplDat = new DateOnly(2015, 7, 14),
DatOntv = new DateOnly(2025, 12, 1), DatBeoord = new DateOnly(2025, 12, 20),
BeoordRes = "A",
BeoordMotiv = "Buitenlands diploma komt niet overeen met een erkend Nederlands diploma-niveau.",
MutDat = new DateTime(2025, 12, 20, 14, 5, 0), MutUser = "seed",
},
};
await using var transaction = await db.Database.BeginTransactionAsync();
await db.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.AANVR ON");
db.Aanvragen.AddRange(rows);
await db.SaveChangesAsync();
await db.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.AANVR OFF");
await transaction.CommitAsync();
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY src/Legacy.Api/ src/Legacy.Api/
# 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/Legacy.Api/Legacy.Api.csproj && \
dotnet publish src/Legacy.Api/Legacy.Api.csproj -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Legacy.Api.dll"]
@@ -0,0 +1,127 @@
using Legacy.Api.Data;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace Legacy.Api.Endpoints;
public static class AanvragenEndpoints
{
public static void MapAanvragenEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/aanvragen");
group.MapGet("", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPut("/{id:int}/gegevens", PutGegevens);
group.MapPost("/{id:int}/beoordeling", PostBeoordeling);
group.MapPut("/{id:int}/migratie-vlag", PutMigratieVlag);
}
private static async Task<Ok<List<Aanvraag>>> GetAll(
LegacyDbContext db, string? zoek, string? status)
{
var query = db.Aanvragen.AsQueryable();
if (!string.IsNullOrWhiteSpace(zoek))
{
var term = zoek.ToLower();
query = query.Where(a => a.Naam.ToLower().Contains(term) || a.Bsn.ToLower().Contains(term));
}
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(a => a.StatCd == status);
}
var result = await query.OrderBy(a => a.Id).ToListAsync();
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<Aanvraag>, NotFound>> GetById(LegacyDbContext db, int id)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
return aanvraag is null ? TypedResults.NotFound() : TypedResults.Ok(aanvraag);
}
private static async Task<Results<NoContent, BadRequest<ValidationErrorResponse>, NotFound, Conflict<MigratedResponse>>> PutGegevens(
LegacyDbContext db, int id, GegevensInput input)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
if (aanvraag is null)
{
return TypedResults.NotFound();
}
if (aanvraag.Migrated)
{
return TypedResults.Conflict(
new MigratedResponse("Deze aanvraag wordt beheerd in het nieuwe portaal."));
}
var errors = GegevensValidator.Validate(input);
if (errors.Count > 0)
{
return TypedResults.BadRequest(new ValidationErrorResponse(errors));
}
aanvraag.Naam = input.Surname;
aanvraag.Voorl = input.Initials;
aanvraag.AdresStr = input.Address?.Street;
aanvraag.AdresNr = input.Address?.Number;
aanvraag.AdresPc = input.Address?.PostalCode;
aanvraag.AdresPl = input.Address?.City;
aanvraag.Email = input.Email;
aanvraag.Telnr = input.Phone;
aanvraag.CorrKanaal = string.Equals(input.PreferredChannel, "Email", StringComparison.OrdinalIgnoreCase)
? "E"
: "P";
aanvraag.MutDat = DateTime.Now;
aanvraag.MutUser = "systeem";
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
private static async Task<Results<NoContent, NotFound, Conflict<MigratedResponse>>> PostBeoordeling(
LegacyDbContext db, int id, BeoordelingInput input)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
if (aanvraag is null)
{
return TypedResults.NotFound();
}
if (aanvraag.Migrated)
{
return TypedResults.Conflict(
new MigratedResponse("Deze aanvraag wordt beheerd in het nieuwe portaal."));
}
aanvraag.StatCd = "B";
aanvraag.BeoordRes = input.Res;
aanvraag.BeoordMotiv = input.Motiv;
aanvraag.DatBeoord = DateOnly.FromDateTime(DateTime.Now);
aanvraag.MutDat = DateTime.Now;
aanvraag.MutUser = "systeem";
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
private static async Task<Results<NoContent, NotFound>> PutMigratieVlag(
LegacyDbContext db, int id, MigratieVlagInput input)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
if (aanvraag is null)
{
return TypedResults.NotFound();
}
aanvraag.Migrated = input.Migrated;
aanvraag.MutDat = DateTime.Now;
aanvraag.MutUser = "systeem";
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
}
@@ -0,0 +1,21 @@
namespace Legacy.Api.Endpoints;
public record AdresInput(string? Street, string? Number, string? PostalCode, string? City);
public record GegevensInput(
string Surname,
string? Initials,
AdresInput? Address,
string? Email,
string? Phone,
string PreferredChannel);
public record BeoordelingInput(string Res, string Motiv);
public record MigratieVlagInput(bool Migrated);
public record ValidationError(string Veld, string Code, string Melding);
public record ValidationErrorResponse(IReadOnlyList<ValidationError> Errors);
public record MigratedResponse(string Message);
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
namespace Legacy.Api.Endpoints;
/// <summary>
/// Validates the "gegevens" (particulars) command against the legacy field
/// rules. Collects every violation instead of stopping at the first one.
/// </summary>
public static partial class GegevensValidator
{
public static List<ValidationError> Validate(GegevensInput input)
{
var errors = new List<ValidationError>();
if (string.IsNullOrWhiteSpace(input.Surname))
{
errors.Add(new ValidationError("NAAM", "NAAM_VERPLICHT", "Achternaam is verplicht"));
}
else if (input.Surname.Length > 60)
{
errors.Add(new ValidationError("NAAM", "NAAM_TE_LANG", "Achternaam is te lang"));
}
var street = input.Address?.Street;
var number = input.Address?.Number;
var postalCode = input.Address?.PostalCode;
if (!string.IsNullOrWhiteSpace(street) && string.IsNullOrWhiteSpace(number))
{
errors.Add(new ValidationError("ADRES_NR", "HUISNR_VERPLICHT", "Huisnummer is verplicht"));
}
if (!string.IsNullOrWhiteSpace(postalCode) && !PostcodeRegex().IsMatch(postalCode))
{
errors.Add(new ValidationError("ADRES_PC", "POSTCODE_ONGELDIG", "Postcode ongeldig"));
}
var wantsEmailChannel = string.Equals(input.PreferredChannel, "Email", StringComparison.OrdinalIgnoreCase);
if (wantsEmailChannel && string.IsNullOrWhiteSpace(input.Email))
{
errors.Add(new ValidationError(
"EMAIL", "EMAIL_VERPLICHT_BIJ_KANAAL", "E-mailadres is verplicht bij communicatiekanaal e-mail"));
}
if (!string.IsNullOrWhiteSpace(input.Email) && !EmailRegex().IsMatch(input.Email))
{
errors.Add(new ValidationError("EMAIL", "EMAIL_ONGELDIG", "E-mailadres is ongeldig"));
}
if (!string.IsNullOrWhiteSpace(input.Phone) && !PhoneRegex().IsMatch(input.Phone))
{
errors.Add(new ValidationError("TELNR", "TELNR_ONGELDIG", "Telefoonnummer is ongeldig"));
}
return errors;
}
[GeneratedRegex(@"^[0-9]{4}[A-Z]{2}$")]
private static partial Regex PostcodeRegex();
[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$")]
private static partial Regex EmailRegex();
[GeneratedRegex(@"^[0-9 +]+$")]
private static partial Regex PhoneRegex();
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Microsoft.Data.SqlClient requires real culture data (it resolves
culture info while opening a connection) - invariant globalization mode
makes it throw CultureNotFoundException on every connection attempt.
The base runtime image (non-Alpine, non-chiseled) ships ICU, so turning
this off just works.
-->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,147 @@
// <auto-generated />
using System;
using Legacy.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Legacy.Api.Migrations
{
[DbContext(typeof(LegacyDbContext))]
[Migration("20260730160055_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Legacy.Api.Data.Aanvraag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("AANVR_ID");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AdresNr")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("ADRES_NR");
b.Property<string>("AdresPc")
.HasColumnType("char(6)")
.HasColumnName("ADRES_PC");
b.Property<string>("AdresPl")
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("ADRES_PL");
b.Property<string>("AdresStr")
.HasMaxLength(80)
.HasColumnType("nvarchar(80)")
.HasColumnName("ADRES_STR");
b.Property<string>("BeoordMotiv")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasColumnName("BEOORD_MOTIV");
b.Property<string>("BeoordRes")
.HasColumnType("char(1)")
.HasColumnName("BEOORD_RES");
b.Property<string>("Bsn")
.IsRequired()
.HasColumnType("char(9)")
.HasColumnName("BSN");
b.Property<string>("CorrKanaal")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("char(1)")
.HasDefaultValue("P")
.HasColumnName("CORR_KANAAL");
b.Property<DateOnly?>("DatBeoord")
.HasColumnType("date")
.HasColumnName("DAT_BEOORD");
b.Property<DateOnly>("DatOntv")
.HasColumnType("date")
.HasColumnName("DAT_ONTV");
b.Property<string>("DiplCd")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("DIPL_CD");
b.Property<DateOnly?>("DiplDat")
.HasColumnType("date")
.HasColumnName("DIPL_DAT");
b.Property<string>("DiplLand")
.HasColumnType("char(2)")
.HasColumnName("DIPL_LAND");
b.Property<string>("Email")
.HasMaxLength(120)
.HasColumnType("nvarchar(120)")
.HasColumnName("EMAIL");
b.Property<bool>("Migrated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("MIGRATED");
b.Property<DateTime>("MutDat")
.HasColumnType("datetime2")
.HasColumnName("MUT_DAT");
b.Property<string>("MutUser")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)")
.HasColumnName("MUT_USER");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("NAAM");
b.Property<string>("StatCd")
.IsRequired()
.HasColumnType("char(1)")
.HasColumnName("STAT_CD");
b.Property<string>("Telnr")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("TELNR");
b.Property<string>("Voorl")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("VOORL");
b.HasKey("Id");
b.ToTable("AANVR", "dbo");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Legacy.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "dbo");
migrationBuilder.CreateTable(
name: "AANVR",
schema: "dbo",
columns: table => new
{
AANVR_ID = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BSN = table.Column<string>(type: "char(9)", nullable: false),
NAAM = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
VOORL = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
ADRES_STR = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: true),
ADRES_NR = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
ADRES_PC = table.Column<string>(type: "char(6)", nullable: true),
ADRES_PL = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: true),
EMAIL = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: true),
TELNR = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CORR_KANAAL = table.Column<string>(type: "char(1)", nullable: false, defaultValue: "P"),
STAT_CD = table.Column<string>(type: "char(1)", nullable: false),
DIPL_CD = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
DIPL_LAND = table.Column<string>(type: "char(2)", nullable: true),
DIPL_DAT = table.Column<DateOnly>(type: "date", nullable: true),
DAT_ONTV = table.Column<DateOnly>(type: "date", nullable: false),
DAT_BEOORD = table.Column<DateOnly>(type: "date", nullable: true),
BEOORD_RES = table.Column<string>(type: "char(1)", nullable: true),
BEOORD_MOTIV = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
MIGRATED = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
MUT_DAT = table.Column<DateTime>(type: "datetime2", nullable: false),
MUT_USER = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AANVR", x => x.AANVR_ID);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AANVR",
schema: "dbo");
}
}
}
@@ -0,0 +1,144 @@
// <auto-generated />
using System;
using Legacy.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Legacy.Api.Migrations
{
[DbContext(typeof(LegacyDbContext))]
partial class LegacyDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Legacy.Api.Data.Aanvraag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("AANVR_ID");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AdresNr")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("ADRES_NR");
b.Property<string>("AdresPc")
.HasColumnType("char(6)")
.HasColumnName("ADRES_PC");
b.Property<string>("AdresPl")
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("ADRES_PL");
b.Property<string>("AdresStr")
.HasMaxLength(80)
.HasColumnType("nvarchar(80)")
.HasColumnName("ADRES_STR");
b.Property<string>("BeoordMotiv")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasColumnName("BEOORD_MOTIV");
b.Property<string>("BeoordRes")
.HasColumnType("char(1)")
.HasColumnName("BEOORD_RES");
b.Property<string>("Bsn")
.IsRequired()
.HasColumnType("char(9)")
.HasColumnName("BSN");
b.Property<string>("CorrKanaal")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("char(1)")
.HasDefaultValue("P")
.HasColumnName("CORR_KANAAL");
b.Property<DateOnly?>("DatBeoord")
.HasColumnType("date")
.HasColumnName("DAT_BEOORD");
b.Property<DateOnly>("DatOntv")
.HasColumnType("date")
.HasColumnName("DAT_ONTV");
b.Property<string>("DiplCd")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("DIPL_CD");
b.Property<DateOnly?>("DiplDat")
.HasColumnType("date")
.HasColumnName("DIPL_DAT");
b.Property<string>("DiplLand")
.HasColumnType("char(2)")
.HasColumnName("DIPL_LAND");
b.Property<string>("Email")
.HasMaxLength(120)
.HasColumnType("nvarchar(120)")
.HasColumnName("EMAIL");
b.Property<bool>("Migrated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("MIGRATED");
b.Property<DateTime>("MutDat")
.HasColumnType("datetime2")
.HasColumnName("MUT_DAT");
b.Property<string>("MutUser")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)")
.HasColumnName("MUT_USER");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("NAAM");
b.Property<string>("StatCd")
.IsRequired()
.HasColumnType("char(1)")
.HasColumnName("STAT_CD");
b.Property<string>("Telnr")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("TELNR");
b.Property<string>("Voorl")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("VOORL");
b.HasKey("Id");
b.ToTable("AANVR", "dbo");
});
#pragma warning restore 612, 618
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Legacy.Api.Data;
using Legacy.Api.Endpoints;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<LegacyDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Legacy")));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<LegacyDbContext>();
await db.Database.MigrateAsync();
await LegacySeeder.SeedAsync(db);
}
app.MapAanvragenEndpoints();
app.Run();
+31
View File
@@ -0,0 +1,31 @@
namespace Legacy.Web;
/// <summary>
/// Mirrors the JSON shape returned by Legacy.Api - legacy column vocabulary,
/// camelCase over the wire, matched here case-insensitively.
/// </summary>
public class AanvraagDto
{
public int Id { get; set; }
public string Bsn { get; set; } = "";
public string Naam { get; set; } = "";
public string? Voorl { get; set; }
public string? AdresStr { get; set; }
public string? AdresNr { get; set; }
public string? AdresPc { get; set; }
public string? AdresPl { get; set; }
public string? Email { get; set; }
public string? Telnr { get; set; }
public string CorrKanaal { get; set; } = "P";
public string StatCd { get; set; } = "O";
public string? DiplCd { get; set; }
public string? DiplLand { get; set; }
public DateOnly? DiplDat { get; set; }
public DateOnly DatOntv { get; set; }
public DateOnly? DatBeoord { get; set; }
public string? BeoordRes { get; set; }
public string? BeoordMotiv { get; set; }
public bool Migrated { get; set; }
public DateTime MutDat { get; set; }
public string MutUser { get; set; } = "";
}
+14
View File
@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY src/Legacy.Web/ src/Legacy.Web/
# restore+publish combined in one RUN/layer - see Legacy.Api/Dockerfile for why.
RUN dotnet restore src/Legacy.Web/Legacy.Web.csproj && \
dotnet publish src/Legacy.Web/Legacy.Web.csproj -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Legacy.Web.dll"]
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
+65
View File
@@ -0,0 +1,65 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Legacy.Web;
public enum BeoordelingUitkomst
{
Success,
Conflict,
}
/// <summary>
/// Thin HTTP client for Legacy.Api. Legacy.Web never touches the database
/// directly - it only talks to the backend over HTTP, same as any other
/// caller.
/// </summary>
public class LegacyApiClient(HttpClient httpClient)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<List<AanvraagDto>> GetAllAsync(string? zoek, string? status)
{
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(zoek))
{
query.Add($"zoek={Uri.EscapeDataString(zoek)}");
}
if (!string.IsNullOrWhiteSpace(status))
{
query.Add($"status={Uri.EscapeDataString(status)}");
}
var url = "/api/aanvragen" + (query.Count > 0 ? "?" + string.Join("&", query) : "");
var result = await httpClient.GetFromJsonAsync<List<AanvraagDto>>(url, JsonOptions);
return result ?? [];
}
public async Task<AanvraagDto?> GetByIdAsync(int id)
{
var response = await httpClient.GetAsync($"/api/aanvragen/{id}");
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<AanvraagDto>(JsonOptions);
}
public async Task<BeoordelingUitkomst> SubmitBeoordelingAsync(int id, string res, string motiv)
{
var response = await httpClient.PostAsJsonAsync(
$"/api/aanvragen/{id}/beoordeling", new { res, motiv });
if (response.StatusCode == HttpStatusCode.Conflict)
{
return BeoordelingUitkomst.Conflict;
}
response.EnsureSuccessStatusCode();
return BeoordelingUitkomst.Success;
}
}
@@ -0,0 +1,58 @@
@page "/legacy/aanvraag/{id:int}/beoordeling"
@model Legacy.Web.Pages.BeoordelingModel
<h1>Beoordeling aanvraag #@Model.Aanvraag.Id</h1>
@if (Model.Ingediend)
{
<div class="melding">
<p>De beoordeling is verwerkt.</p>
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Terug naar aanvraag</a></p>
</div>
}
else if (Model.Aanvraag.Migrated)
{
<div class="melding">
<p>Deze aanvraag wordt beheerd in het nieuwe portaal.</p>
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Naar nieuw portaal</a></p>
</div>
}
else
{
@if (Model.Conflict)
{
<div class="melding">
<p>Deze aanvraag wordt inmiddels beheerd in het nieuwe portaal. De beoordeling is niet opgeslagen.</p>
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Naar nieuw portaal</a></p>
</div>
}
else
{
<fieldset>
<legend>Aanvraaggegevens</legend>
<p><label>Naam:</label> @Model.Aanvraag.Naam @Model.Aanvraag.Voorl</p>
<p><label>BSN:</label> @Model.Aanvraag.Bsn</p>
<p><label>Ontvangen:</label> @Model.Aanvraag.DatOntv.ToString("dd-MM-yyyy")</p>
</fieldset>
<form method="post">
<fieldset>
<legend>Beoordeling</legend>
<p>
<label for="res">Uitkomst:</label>
<select id="res" name="Res">
<option value="G" selected="@(Model.Res == "G")">Goedgekeurd</option>
<option value="A" selected="@(Model.Res == "A")">Afgewezen</option>
</select>
</p>
<p>
<label for="motiv">Motivatie:</label><br />
<textarea id="motiv" name="Motiv" rows="5" cols="60">@Model.Motiv</textarea>
</p>
<p>
<input type="submit" value="Beoordeling opslaan" />
</p>
</fieldset>
</form>
}
}
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Legacy.Web.Pages;
public class BeoordelingModel(LegacyApiClient client) : PageModel
{
public AanvraagDto Aanvraag { get; set; } = null!;
public bool Ingediend { get; set; }
public bool Conflict { get; set; }
[BindProperty]
public string Res { get; set; } = "G";
[BindProperty]
public string Motiv { get; set; } = "";
public async Task<IActionResult> OnGetAsync(int id)
{
var aanvraag = await client.GetByIdAsync(id);
if (aanvraag is null)
{
return NotFound();
}
Aanvraag = aanvraag;
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
var aanvraag = await client.GetByIdAsync(id);
if (aanvraag is null)
{
return NotFound();
}
Aanvraag = aanvraag;
if (Aanvraag.Migrated)
{
// Blocked page is rendered from the razor markup; the write
// endpoint is never called for a migrated case.
return Page();
}
var uitkomst = await client.SubmitBeoordelingAsync(id, Res, Motiv);
if (uitkomst == BeoordelingUitkomst.Conflict)
{
Conflict = true;
Aanvraag = (await client.GetByIdAsync(id))!;
return Page();
}
Ingediend = true;
return Page();
}
}
+59
View File
@@ -0,0 +1,59 @@
@page "/legacy"
@model Legacy.Web.Pages.IndexModel
<h1>Aanvragen diplomawaardering</h1>
<form method="get">
<label for="zoek">Zoeken (naam/BSN):</label>
<input type="text" id="zoek" name="Zoek" value="@Model.Zoek" />
&nbsp;
<label for="status" style="width:auto;">Status:</label>
<select id="status" name="Status">
<option value="">(alle)</option>
<option value="O" selected="@(Model.Status == "O")">open</option>
<option value="B" selected="@(Model.Status == "B")">beoordeeld</option>
<option value="A" selected="@(Model.Status == "A")">afgerond</option>
<option value="X" selected="@(Model.Status == "X")">ingetrokken</option>
</select>
&nbsp;
<input type="submit" value="Filteren" />
</form>
<br />
<table>
<thead>
<tr>
<th>ID</th>
<th>BSN</th>
<th>Naam</th>
<th>Status</th>
<th>Ontvangen</th>
<th>Kanaal</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
@foreach (var a in Model.Aanvragen)
{
<tr class="@(a.Migrated ? "migrated" : "")">
<td>@a.Id</td>
<td>@a.Bsn</td>
<td>@a.Naam @a.Voorl</td>
<td>@Legacy.Web.Pages.IndexModel.StatusLabel(a.StatCd)</td>
<td>@a.DatOntv.ToString("dd-MM-yyyy")</td>
<td>@(a.CorrKanaal == "E" ? "e-mail" : "post")</td>
<td>
@if (a.Migrated)
{
<a href="/worklist/legacy/@a.Id">beheerd in nieuw portaal</a>
}
else
{
<a href="/legacy/aanvraag/@a.Id/beoordeling">Beoordelen</a>
}
</td>
</tr>
}
</tbody>
</table>
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Legacy.Web.Pages;
public class IndexModel(LegacyApiClient client) : PageModel
{
public List<AanvraagDto> Aanvragen { get; set; } = [];
[BindProperty(SupportsGet = true)]
public string? Zoek { get; set; }
[BindProperty(SupportsGet = true)]
public string? Status { get; set; }
public async Task OnGetAsync()
{
Aanvragen = await client.GetAllAsync(Zoek, Status);
}
public static string StatusLabel(string statCd) => statCd switch
{
"O" => "open",
"B" => "beoordeeld",
"A" => "afgerond",
"X" => "ingetrokken",
_ => statCd,
};
}
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Diplomawaardering - Legacy systeem</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
background-color: #d4d0c8;
color: #000000;
margin: 0;
padding: 0 0 24px 0;
}
h1, h2 {
font-family: "Times New Roman", Times, serif;
}
.banner {
background-color: #000080;
color: #ffffff;
padding: 10px 16px;
font-family: "Times New Roman", Times, serif;
font-size: 1.3em;
border-bottom: 2px solid #000000;
}
.content {
padding: 16px;
}
table {
border-collapse: collapse;
width: 100%;
background-color: #ffffff;
}
table, th, td {
border: 1px solid #000000;
}
th, td {
padding: 4px 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: #c0c0c0;
}
tr.migrated {
color: #808080;
background-color: #eeeeee;
}
a {
color: #0000ee;
}
button, input[type=submit] {
background-color: #c0c0c0;
border: 2px outset #808080;
padding: 4px 14px;
font-family: Arial, Helvetica, sans-serif;
font-size: 1em;
}
button:active, input[type=submit]:active {
border-style: inset;
}
fieldset {
border: 1px solid #000000;
margin-bottom: 12px;
}
label {
display: inline-block;
width: 140px;
font-weight: bold;
}
input[type=text], select, textarea {
border: 1px solid #000000;
font-family: Arial, Helvetica, sans-serif;
}
.melding {
border: 1px solid #000000;
background-color: #ffffcc;
padding: 8px;
margin-bottom: 12px;
}
</style>
</head>
<body>
<div class="banner">Diplomawaardering &mdash; Aanvraagregistratie (Legacy systeem)</div>
<div class="content">
@RenderBody()
</div>
</body>
</html>
@@ -0,0 +1,3 @@
@namespace Legacy.Web.Pages
@using Legacy.Web
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+19
View File
@@ -0,0 +1,19 @@
using Legacy.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var legacyApiBaseUrl = builder.Configuration["Services:LegacyApi:BaseUrl"]
?? "http://localhost:8081";
builder.Services.AddHttpClient<LegacyApiClient>(client =>
{
client.BaseAddress = new Uri(legacyApiBaseUrl);
});
var app = builder.Build();
app.MapRazorPages();
app.Run();
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
+157
View File
@@ -0,0 +1,157 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Behandel portaal (placeholder)</title>
</head>
<body>
<h1>Behandel portaal — werkvoorraad (placeholder, session 2 replaces this with Angular)</h1>
<p>
<label>Bucket: <select id="bucket"><option value="">Alles</option><option value="Open">Te beoordelen</option><option value="Beoordeeld">Beoordeeld</option><option value="Ingetrokken">Ingetrokken</option></select></label>
<label>Origin: <select id="origin"><option value="">alles</option><option value="Legacy">legacy</option><option value="Owned">nieuw proces</option></select></label>
<label>Zoek: <input id="search" type="text"></label>
<button onclick="loadWorklist()">Ververs</button>
</p>
<table border="1" cellpadding="4">
<thead>
<tr><th>Origin</th><th>Referentie</th><th>Naam</th><th>BSN</th><th>Ontvangen</th><th>Uitkomst</th><th>Processtatus</th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
<h2>Detail</h2>
<pre id="detail">Kies een rij (klik erop) om details te laden.</pre>
<div id="actions"></div>
<script>
let currentKey = null;
async function loadWorklist() {
const bucket = document.getElementById('bucket').value;
const origin = document.getElementById('origin').value;
const search = document.getElementById('search').value;
const params = new URLSearchParams();
if (bucket) params.set('bucket', bucket);
if (origin) params.set('origin', origin);
if (search) params.set('search', search);
const res = await fetch('/api/worklist?' + params.toString());
const data = await res.json();
const rows = document.getElementById('rows');
rows.innerHTML = '';
for (const item of data.items) {
const tr = document.createElement('tr');
const key = item.origin === 'Legacy' ? `legacy/${item.legacyAanvraagId}` : `owned/${item.registrationApplicationId}`;
tr.innerHTML = `<td>${item.origin}</td><td>${item.legacyAanvraagId ?? item.registrationApplicationId}</td>` +
`<td>${item.surname} ${item.initials}</td><td>${item.bsn}</td><td>${item.receivedOn}</td>` +
`<td>${item.assessmentOutcome ?? ''}</td><td>${item.processStatus ?? 'n.v.t.'}</td>`;
tr.style.cursor = 'pointer';
tr.onclick = () => loadDetail(key);
rows.appendChild(tr);
}
}
async function loadDetail(key) {
currentKey = key;
const res = await fetch(`/api/worklist/${key}`);
if (!res.ok) {
document.getElementById('detail').textContent = `Fout: ${res.status}`;
document.getElementById('actions').innerHTML = '';
return;
}
const detail = await res.json();
document.getElementById('detail').textContent = JSON.stringify(detail, null, 2);
renderActions(detail);
}
function renderActions(detail) {
const el = document.getElementById('actions');
el.innerHTML = '';
const a = detail.actions;
addButton(el, `Gegevens wijzigen (${a.editApplicantDetails.mode})`, async () => {
const surname = prompt('Surname', detail.surname);
if (surname === null) return;
const initials = prompt('Initials', detail.initials);
const street = prompt('Street (leeg = geen adres)', detail.address?.street ?? '');
const number = street ? prompt('Number', detail.address?.number ?? '') : null;
const postalCode = street ? prompt('Postal code', detail.address?.postalCode ?? '') : null;
const city = street ? prompt('City', detail.address?.city ?? '') : null;
const email = prompt('Email', detail.email ?? '');
const phone = prompt('Phone', detail.phone ?? '');
const preferredChannel = prompt('Preferred channel (Post/Email)', detail.preferredChannel);
const body = {
surname, initials,
address: street ? { street, number, postalCode, city } : null,
email, phone, preferredChannel,
};
const res = await fetch(a.editApplicantDetails.href, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const text = await res.text();
alert(`${res.status}: ${text}`);
loadDetail(currentKey);
});
if (a.recordAssessment.mode === 'redirect') {
addLink(el, 'Beoordeling vastleggen (verlaat portaal — legacy valideert)', a.recordAssessment.href);
} else {
addButton(el, 'Beoordeling vastleggen', async () => {
const verified = prompt('Verified items (comma separated: document,land,datum)', 'document,land,datum');
const exceptionReason = verified ? null : prompt('Exception reason (verplicht als geen items geverifieerd)');
const outcome = prompt('Outcome (Approved/Rejected)', 'Approved');
const rejectionCategory = outcome === 'Rejected' ? prompt('Rejection category (onvolledig/niet erkend/niet bevoegd/anders)') : null;
const motivation = prompt('Motivation (min 20 chars, 50 if "anders")');
const body = {
verifiedItems: verified ? verified.split(',').map(s => s.trim()) : [],
exceptionReason, outcome, rejectionCategory, motivation,
};
const res = await fetch(a.recordAssessment.href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const text = await res.text();
alert(`${res.status}: ${text}`);
loadDetail(currentKey);
});
}
if (a.takeOwnership) {
addButton(el, 'Overnemen in nieuw systeem', async () => {
if (!confirm('Dit maakt het nieuwe systeem eigenaar van deze zaak. Doorgaan?')) return;
const res = await fetch(a.takeOwnership.href, { method: 'POST' });
const text = await res.text();
alert(`${res.status}: ${text}`);
loadWorklist();
if (res.ok) {
const body = JSON.parse(text);
loadDetail(`owned/${body.registrationApplicationId}`);
}
});
}
if (a.releaseOwnership) {
addButton(el, 'Overname terugdraaien', async () => {
const res = await fetch(a.releaseOwnership.href, { method: 'DELETE' });
const text = await res.text();
alert(`${res.status}: ${text}`);
loadWorklist();
});
}
}
function addButton(container, label, onClick) {
const btn = document.createElement('button');
btn.textContent = label;
btn.onclick = onClick;
container.appendChild(btn);
container.appendChild(document.createElement('br'));
}
function addLink(container, label, href) {
const a = document.createElement('a');
a.textContent = label;
a.href = href;
container.appendChild(a);
container.appendChild(document.createElement('br'));
}
loadWorklist();
</script>
</body>
</html>
+23
View File
@@ -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));
}
}
+21
View File
@@ -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>
+46
View File
@@ -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;
}
}
+19
View File
@@ -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));
}
+66
View File
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More