From a6a1abbe9c9ee3b3d9223203d3983310ede9db99 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 31 Jul 2026 07:57:26 +0200 Subject: [PATCH] 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 --- .claude/scheduled_tasks.lock | 1 - .gitignore | 7 + README.md | 135 ++++++++++++ case-framework/Dockerfile | 13 ++ .../src/CaseFramework.Api/CaseDbContext.cs | 40 ++++ .../CaseFramework.Api.csproj | 15 ++ .../src/CaseFramework.Api/Models.cs | 52 +++++ .../src/CaseFramework.Api/Program.cs | 186 ++++++++++++++++ docker-compose.yml | 95 ++++++++ ...ADR-001-decision-independent-of-closure.md | 49 +++++ ...002-write-through-has-no-business-rules.md | 50 +++++ .../ADR-003-ownership-is-taken-per-case.md | 59 +++++ docs/sync-not-implemented.md | 26 +++ legacy/src/Legacy.Api/Data/Aanvraag.cs | 32 +++ legacy/src/Legacy.Api/Data/LegacyDbContext.cs | 41 ++++ legacy/src/Legacy.Api/Data/LegacySeeder.cs | 144 +++++++++++++ legacy/src/Legacy.Api/Dockerfile | 17 ++ .../Endpoints/AanvragenEndpoints.cs | 127 +++++++++++ .../Legacy.Api/Endpoints/AanvragenModels.cs | 21 ++ .../Legacy.Api/Endpoints/GegevensValidator.cs | 66 ++++++ legacy/src/Legacy.Api/Legacy.Api.csproj | 25 +++ .../20260730160055_InitialCreate.Designer.cs | 147 +++++++++++++ .../20260730160055_InitialCreate.cs | 60 ++++++ .../LegacyDbContextModelSnapshot.cs | 144 +++++++++++++ legacy/src/Legacy.Api/Program.cs | 21 ++ legacy/src/Legacy.Web/AanvraagDto.cs | 31 +++ legacy/src/Legacy.Web/Dockerfile | 14 ++ legacy/src/Legacy.Web/Legacy.Web.csproj | 10 + legacy/src/Legacy.Web/LegacyApiClient.cs | 65 ++++++ .../src/Legacy.Web/Pages/Beoordeling.cshtml | 58 +++++ .../Legacy.Web/Pages/Beoordeling.cshtml.cs | 60 ++++++ legacy/src/Legacy.Web/Pages/Index.cshtml | 59 +++++ legacy/src/Legacy.Web/Pages/Index.cshtml.cs | 29 +++ .../Legacy.Web/Pages/Shared/_Layout.cshtml | 88 ++++++++ .../src/Legacy.Web/Pages/_ViewImports.cshtml | 3 + legacy/src/Legacy.Web/Pages/_ViewStart.cshtml | 3 + legacy/src/Legacy.Web/Program.cs | 19 ++ new-frontend/Dockerfile | 3 + new-frontend/index.html | 157 ++++++++++++++ new/Dockerfile | 23 ++ .../Contracts/CaseDetailResponseFactory.cs | 54 +++++ new/src/New.Api/Contracts/RequestContracts.cs | 45 ++++ .../New.Api/Contracts/WorklistContracts.cs | 83 +++++++ .../New.Api/Endpoints/AssessmentEndpoints.cs | 46 ++++ new/src/New.Api/Endpoints/DetailsEndpoints.cs | 47 ++++ .../New.Api/Endpoints/DiagnosticsEndpoints.cs | 12 ++ .../New.Api/Endpoints/OwnershipEndpoints.cs | 42 ++++ .../New.Api/Endpoints/WorklistEndpoints.cs | 86 ++++++++ new/src/New.Api/New.Api.csproj | 21 ++ new/src/New.Api/Program.cs | 46 ++++ .../Resolution/ApplicationSourceResolver.cs | 33 +++ .../New.Api/Seeding/OwnedApplicationSeeder.cs | 126 +++++++++++ .../Assessments/RecordAssessmentCommand.cs | 10 + .../Assessments/RecordAssessmentResult.cs | 23 ++ .../RecordOwnedAssessmentHandler.cs | 58 +++++ .../New.Application/New.Application.csproj | 26 +++ .../Ownership/ReleaseOwnershipHandler.cs | 45 ++++ .../Ownership/ReleaseOwnershipResult.cs | 17 ++ .../Ownership/TakeOwnershipHandler.cs | 114 ++++++++++ .../Ownership/TakeOwnershipResult.cs | 25 +++ .../Ports/IApplicationSource.cs | 14 ++ .../Ports/ICaseFrameworkGateway.cs | 25 +++ .../Ports/ILegacyCaseGateway.cs | 39 ++++ .../Ports/ILegacyWorklistReader.cs | 9 + .../Ports/IOwnedWorklistReader.cs | 14 ++ .../Ports/IOwnershipRegistry.cs | 30 +++ .../IRegistrationApplicationRepository.cs | 15 ++ new/src/New.Application/Ports/IUnitOfWork.cs | 12 ++ .../New.Application/Worklist/AddressData.cs | 10 + .../Worklist/AssessmentData.cs | 10 + .../New.Application/Worklist/CaseDetail.cs | 34 +++ .../New.Application/Worklist/WorklistItem.cs | 27 +++ .../Worklist/WorklistOrigin.cs | 8 + .../WriteThrough/ApplicantDetailsCommand.cs | 16 ++ .../UpdateOwnedApplicantDetailsHandler.cs | 60 ++++++ .../UpdateOwnedApplicantDetailsResult.cs | 20 ++ .../WriteThrough/WriteThroughResult.cs | 28 +++ .../DomainInvariantViolationException.cs | 22 ++ new/src/New.Domain/New.Domain.csproj | 19 ++ new/src/New.Domain/RegistrationApplication.cs | 186 ++++++++++++++++ new/src/New.Domain/ValueObjects/Address.cs | 42 ++++ new/src/New.Domain/ValueObjects/Assessment.cs | 101 +++++++++ new/src/New.Domain/ValueObjects/Bsn.cs | 66 ++++++ .../New.Domain/ValueObjects/CaseReference.cs | 42 ++++ .../New.Domain/ValueObjects/ContactDetails.cs | 42 ++++ .../ValueObjects/DiplomaEvidence.cs | 30 +++ new/src/New.Domain/ValueObjects/PersonName.cs | 32 +++ .../CaseFrameworkClient.cs | 54 +++++ .../CaseFrameworkGateway.cs | 40 ++++ .../Dtos/CaseFrameworkDtos.cs | 41 ++++ .../New.Infrastructure.CaseFramework.csproj | 26 +++ .../Options/CaseFrameworkOptions.cs | 8 + .../ServiceCollectionExtensions.cs | 40 ++++ .../AmsterdamClock.cs | 19 ++ .../Dtos/LegacyAanvraagDto.cs | 34 +++ .../Dtos/LegacyWriteThroughDtos.cs | 29 +++ .../LegacyAanvraagMapper.cs | 132 ++++++++++++ .../LegacyAanvraagStatus.cs | 30 +++ .../LegacyBackendClient.cs | 83 +++++++ .../LegacyCallCounter.cs | 26 +++ .../LegacyCaseDetailProjection.cs | 76 +++++++ .../LegacyCaseGateway.cs | 60 ++++++ .../LegacyCaseSource.cs | 27 +++ .../LegacyDetailsWriteThroughTranslator.cs | 69 ++++++ .../LegacyWorklistReader.cs | 20 ++ .../New.Infrastructure.Legacy.csproj | 28 +++ .../Options/LegacyBackendOptions.cs | 8 + .../ServiceCollectionExtensions.cs | 62 ++++++ .../CaseDetailProjection.cs | 67 ++++++ .../LegacyOwnershipRowConfiguration.cs | 46 ++++ ...istrationApplicationRecordConfiguration.cs | 25 +++ .../Entities/LegacyOwnershipRow.cs | 15 ++ .../Entities/RegistrationApplicationRecord.cs | 47 ++++ .../20260730161623_InitialCreate.Designer.cs | 147 +++++++++++++ .../20260730161623_InitialCreate.cs | 80 +++++++ .../Migrations/NewDbContextModelSnapshot.cs | 144 +++++++++++++ .../New.Infrastructure.Persistence.csproj | 32 +++ .../NewDbContext.cs | 16 ++ .../NewDbContextFactory.cs | 21 ++ .../OwnedApplicationSource.cs | 33 +++ .../Repositories/OwnedWorklistReader.cs | 21 ++ .../Repositories/OwnershipRegistry.cs | 55 +++++ .../RegistrationApplicationRepository.cs | 148 +++++++++++++ .../Repositories/UnitOfWork.cs | 14 ++ .../ServiceCollectionExtensions.cs | 32 +++ .../Architecture.Tests.csproj | 36 ++++ .../Architecture.Tests/ArchitectureTests.cs | 203 ++++++++++++++++++ proxy/nginx.conf | 34 +++ scripts/smoke.sh | 145 +++++++++++++ 129 files changed, 6379 insertions(+), 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 .gitignore create mode 100644 README.md create mode 100644 case-framework/Dockerfile create mode 100644 case-framework/src/CaseFramework.Api/CaseDbContext.cs create mode 100644 case-framework/src/CaseFramework.Api/CaseFramework.Api.csproj create mode 100644 case-framework/src/CaseFramework.Api/Models.cs create mode 100644 case-framework/src/CaseFramework.Api/Program.cs create mode 100644 docker-compose.yml create mode 100644 docs/adr/ADR-001-decision-independent-of-closure.md create mode 100644 docs/adr/ADR-002-write-through-has-no-business-rules.md create mode 100644 docs/adr/ADR-003-ownership-is-taken-per-case.md create mode 100644 docs/sync-not-implemented.md create mode 100644 legacy/src/Legacy.Api/Data/Aanvraag.cs create mode 100644 legacy/src/Legacy.Api/Data/LegacyDbContext.cs create mode 100644 legacy/src/Legacy.Api/Data/LegacySeeder.cs create mode 100644 legacy/src/Legacy.Api/Dockerfile create mode 100644 legacy/src/Legacy.Api/Endpoints/AanvragenEndpoints.cs create mode 100644 legacy/src/Legacy.Api/Endpoints/AanvragenModels.cs create mode 100644 legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs create mode 100644 legacy/src/Legacy.Api/Legacy.Api.csproj create mode 100644 legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.Designer.cs create mode 100644 legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.cs create mode 100644 legacy/src/Legacy.Api/Migrations/LegacyDbContextModelSnapshot.cs create mode 100644 legacy/src/Legacy.Api/Program.cs create mode 100644 legacy/src/Legacy.Web/AanvraagDto.cs create mode 100644 legacy/src/Legacy.Web/Dockerfile create mode 100644 legacy/src/Legacy.Web/Legacy.Web.csproj create mode 100644 legacy/src/Legacy.Web/LegacyApiClient.cs create mode 100644 legacy/src/Legacy.Web/Pages/Beoordeling.cshtml create mode 100644 legacy/src/Legacy.Web/Pages/Beoordeling.cshtml.cs create mode 100644 legacy/src/Legacy.Web/Pages/Index.cshtml create mode 100644 legacy/src/Legacy.Web/Pages/Index.cshtml.cs create mode 100644 legacy/src/Legacy.Web/Pages/Shared/_Layout.cshtml create mode 100644 legacy/src/Legacy.Web/Pages/_ViewImports.cshtml create mode 100644 legacy/src/Legacy.Web/Pages/_ViewStart.cshtml create mode 100644 legacy/src/Legacy.Web/Program.cs create mode 100644 new-frontend/Dockerfile create mode 100644 new-frontend/index.html create mode 100644 new/Dockerfile create mode 100644 new/src/New.Api/Contracts/CaseDetailResponseFactory.cs create mode 100644 new/src/New.Api/Contracts/RequestContracts.cs create mode 100644 new/src/New.Api/Contracts/WorklistContracts.cs create mode 100644 new/src/New.Api/Endpoints/AssessmentEndpoints.cs create mode 100644 new/src/New.Api/Endpoints/DetailsEndpoints.cs create mode 100644 new/src/New.Api/Endpoints/DiagnosticsEndpoints.cs create mode 100644 new/src/New.Api/Endpoints/OwnershipEndpoints.cs create mode 100644 new/src/New.Api/Endpoints/WorklistEndpoints.cs create mode 100644 new/src/New.Api/New.Api.csproj create mode 100644 new/src/New.Api/Program.cs create mode 100644 new/src/New.Api/Resolution/ApplicationSourceResolver.cs create mode 100644 new/src/New.Api/Seeding/OwnedApplicationSeeder.cs create mode 100644 new/src/New.Application/Assessments/RecordAssessmentCommand.cs create mode 100644 new/src/New.Application/Assessments/RecordAssessmentResult.cs create mode 100644 new/src/New.Application/Assessments/RecordOwnedAssessmentHandler.cs create mode 100644 new/src/New.Application/New.Application.csproj create mode 100644 new/src/New.Application/Ownership/ReleaseOwnershipHandler.cs create mode 100644 new/src/New.Application/Ownership/ReleaseOwnershipResult.cs create mode 100644 new/src/New.Application/Ownership/TakeOwnershipHandler.cs create mode 100644 new/src/New.Application/Ownership/TakeOwnershipResult.cs create mode 100644 new/src/New.Application/Ports/IApplicationSource.cs create mode 100644 new/src/New.Application/Ports/ICaseFrameworkGateway.cs create mode 100644 new/src/New.Application/Ports/ILegacyCaseGateway.cs create mode 100644 new/src/New.Application/Ports/ILegacyWorklistReader.cs create mode 100644 new/src/New.Application/Ports/IOwnedWorklistReader.cs create mode 100644 new/src/New.Application/Ports/IOwnershipRegistry.cs create mode 100644 new/src/New.Application/Ports/IRegistrationApplicationRepository.cs create mode 100644 new/src/New.Application/Ports/IUnitOfWork.cs create mode 100644 new/src/New.Application/Worklist/AddressData.cs create mode 100644 new/src/New.Application/Worklist/AssessmentData.cs create mode 100644 new/src/New.Application/Worklist/CaseDetail.cs create mode 100644 new/src/New.Application/Worklist/WorklistItem.cs create mode 100644 new/src/New.Application/Worklist/WorklistOrigin.cs create mode 100644 new/src/New.Application/WriteThrough/ApplicantDetailsCommand.cs create mode 100644 new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsHandler.cs create mode 100644 new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsResult.cs create mode 100644 new/src/New.Application/WriteThrough/WriteThroughResult.cs create mode 100644 new/src/New.Domain/DomainInvariantViolationException.cs create mode 100644 new/src/New.Domain/New.Domain.csproj create mode 100644 new/src/New.Domain/RegistrationApplication.cs create mode 100644 new/src/New.Domain/ValueObjects/Address.cs create mode 100644 new/src/New.Domain/ValueObjects/Assessment.cs create mode 100644 new/src/New.Domain/ValueObjects/Bsn.cs create mode 100644 new/src/New.Domain/ValueObjects/CaseReference.cs create mode 100644 new/src/New.Domain/ValueObjects/ContactDetails.cs create mode 100644 new/src/New.Domain/ValueObjects/DiplomaEvidence.cs create mode 100644 new/src/New.Domain/ValueObjects/PersonName.cs create mode 100644 new/src/New.Infrastructure.CaseFramework/CaseFrameworkClient.cs create mode 100644 new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs create mode 100644 new/src/New.Infrastructure.CaseFramework/Dtos/CaseFrameworkDtos.cs create mode 100644 new/src/New.Infrastructure.CaseFramework/New.Infrastructure.CaseFramework.csproj create mode 100644 new/src/New.Infrastructure.CaseFramework/Options/CaseFrameworkOptions.cs create mode 100644 new/src/New.Infrastructure.CaseFramework/ServiceCollectionExtensions.cs create mode 100644 new/src/New.Infrastructure.Legacy/AmsterdamClock.cs create mode 100644 new/src/New.Infrastructure.Legacy/Dtos/LegacyAanvraagDto.cs create mode 100644 new/src/New.Infrastructure.Legacy/Dtos/LegacyWriteThroughDtos.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyAanvraagMapper.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyAanvraagStatus.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyBackendClient.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyCallCounter.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyCaseDetailProjection.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyCaseGateway.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs create mode 100644 new/src/New.Infrastructure.Legacy/LegacyWorklistReader.cs create mode 100644 new/src/New.Infrastructure.Legacy/New.Infrastructure.Legacy.csproj create mode 100644 new/src/New.Infrastructure.Legacy/Options/LegacyBackendOptions.cs create mode 100644 new/src/New.Infrastructure.Legacy/ServiceCollectionExtensions.cs create mode 100644 new/src/New.Infrastructure.Persistence/CaseDetailProjection.cs create mode 100644 new/src/New.Infrastructure.Persistence/Configurations/LegacyOwnershipRowConfiguration.cs create mode 100644 new/src/New.Infrastructure.Persistence/Configurations/RegistrationApplicationRecordConfiguration.cs create mode 100644 new/src/New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs create mode 100644 new/src/New.Infrastructure.Persistence/Entities/RegistrationApplicationRecord.cs create mode 100644 new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.Designer.cs create mode 100644 new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.cs create mode 100644 new/src/New.Infrastructure.Persistence/Migrations/NewDbContextModelSnapshot.cs create mode 100644 new/src/New.Infrastructure.Persistence/New.Infrastructure.Persistence.csproj create mode 100644 new/src/New.Infrastructure.Persistence/NewDbContext.cs create mode 100644 new/src/New.Infrastructure.Persistence/NewDbContextFactory.cs create mode 100644 new/src/New.Infrastructure.Persistence/OwnedApplicationSource.cs create mode 100644 new/src/New.Infrastructure.Persistence/Repositories/OwnedWorklistReader.cs create mode 100644 new/src/New.Infrastructure.Persistence/Repositories/OwnershipRegistry.cs create mode 100644 new/src/New.Infrastructure.Persistence/Repositories/RegistrationApplicationRepository.cs create mode 100644 new/src/New.Infrastructure.Persistence/Repositories/UnitOfWork.cs create mode 100644 new/src/New.Infrastructure.Persistence/ServiceCollectionExtensions.cs create mode 100644 new/tests/Architecture.Tests/Architecture.Tests.csproj create mode 100644 new/tests/Architecture.Tests/ArchitectureTests.cs create mode 100644 proxy/nginx.conf create mode 100755 scripts/smoke.sh diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index d2eb03c..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"242e77f6-a2f0-4051-97fa-f360cc57a6ce","pid":386013,"procStart":"7345046","acquiredAt":1785426330459} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85b5532 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +node_modules/ +dist/ +.vs/ +*.user +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b5eb3b --- /dev/null +++ b/README.md @@ -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 1001–1012, 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. diff --git a/case-framework/Dockerfile b/case-framework/Dockerfile new file mode 100644 index 0000000..56905a6 --- /dev/null +++ b/case-framework/Dockerfile @@ -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"] diff --git a/case-framework/src/CaseFramework.Api/CaseDbContext.cs b/case-framework/src/CaseFramework.Api/CaseDbContext.cs new file mode 100644 index 0000000..059dbc3 --- /dev/null +++ b/case-framework/src/CaseFramework.Api/CaseDbContext.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; + +namespace CaseFramework.Api; + +public class CaseDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Cases => Set(); + public DbSet Tasks => Set(); + public DbSet TimelineEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(c => c.Id); + // Npgsql maps List 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(entity => + { + entity.HasKey(t => t.Id); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(t => t.Id); + }); + } +} diff --git a/case-framework/src/CaseFramework.Api/CaseFramework.Api.csproj b/case-framework/src/CaseFramework.Api/CaseFramework.Api.csproj new file mode 100644 index 0000000..5bf9c9b --- /dev/null +++ b/case-framework/src/CaseFramework.Api/CaseFramework.Api.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + true + + + + + + + + diff --git a/case-framework/src/CaseFramework.Api/Models.cs b/case-framework/src/CaseFramework.Api/Models.cs new file mode 100644 index 0000000..6863595 --- /dev/null +++ b/case-framework/src/CaseFramework.Api/Models.cs @@ -0,0 +1,52 @@ +namespace CaseFramework.Api; + +/// +/// A case managed by the framework. This is the framework's own aggregate; +/// callers reference it by and may attach their own +/// for correlation. +/// +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 Participants { get; set; } = new(); + + public List Tasks { get; set; } = new(); + public List TimelineEntries { get; set; } = new(); +} + +/// A unit of work that must be completed before a case may be closed. +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; +} + +/// An immutable audit entry appended to a case's history. +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"; +} diff --git a/case-framework/src/CaseFramework.Api/Program.cs b/case-framework/src/CaseFramework.Api/Program.cs new file mode 100644 index 0000000..b27e1a4 --- /dev/null +++ b/case-framework/src/CaseFramework.Api/Program.cs @@ -0,0 +1,186 @@ +using CaseFramework.Api; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(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(); + 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(), + }; + 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? Participants); + +internal record CreateCaseResponse(Guid Id, string ProcessStatus); + +internal record CaseResponse( + Guid Id, + string CaseTypeCode, + string ExternalReference, + string ProcessStatus, + List 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 Entries); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa6845b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/adr/ADR-001-decision-independent-of-closure.md b/docs/adr/ADR-001-decision-independent-of-closure.md new file mode 100644 index 0000000..648bbfe --- /dev/null +++ b/docs/adr/ADR-001-decision-independent-of-closure.md @@ -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. diff --git a/docs/adr/ADR-002-write-through-has-no-business-rules.md b/docs/adr/ADR-002-write-through-has-no-business-rules.md new file mode 100644 index 0000000..4f8f34d --- /dev/null +++ b/docs/adr/ADR-002-write-through-has-no-business-rules.md @@ -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. diff --git a/docs/adr/ADR-003-ownership-is-taken-per-case.md b/docs/adr/ADR-003-ownership-is-taken-per-case.md new file mode 100644 index 0000000..aeb5227 --- /dev/null +++ b/docs/adr/ADR-003-ownership-is-taken-per-case.md @@ -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. diff --git a/docs/sync-not-implemented.md b/docs/sync-not-implemented.md new file mode 100644 index 0000000..898d95d --- /dev/null +++ b/docs/sync-not-implemented.md @@ -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. diff --git a/legacy/src/Legacy.Api/Data/Aanvraag.cs b/legacy/src/Legacy.Api/Data/Aanvraag.cs new file mode 100644 index 0000000..f02f425 --- /dev/null +++ b/legacy/src/Legacy.Api/Data/Aanvraag.cs @@ -0,0 +1,32 @@ +namespace Legacy.Api.Data; + +/// +/// 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. +/// +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"; +} diff --git a/legacy/src/Legacy.Api/Data/LegacyDbContext.cs b/legacy/src/Legacy.Api/Data/LegacyDbContext.cs new file mode 100644 index 0000000..7e9e733 --- /dev/null +++ b/legacy/src/Legacy.Api/Data/LegacyDbContext.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; + +namespace Legacy.Api.Data; + +public class LegacyDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Aanvragen => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(); + }); + } +} diff --git a/legacy/src/Legacy.Api/Data/LegacySeeder.cs b/legacy/src/Legacy.Api/Data/LegacySeeder.cs new file mode 100644 index 0000000..a61e014 --- /dev/null +++ b/legacy/src/Legacy.Api/Data/LegacySeeder.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore; + +namespace Legacy.Api.Data; + +/// +/// 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. +/// +public static class LegacySeeder +{ + public static async Task SeedAsync(LegacyDbContext db) + { + if (await db.Aanvragen.AnyAsync()) + { + return; + } + + var rows = new List + { + 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(); + } +} diff --git a/legacy/src/Legacy.Api/Dockerfile b/legacy/src/Legacy.Api/Dockerfile new file mode 100644 index 0000000..71264f8 --- /dev/null +++ b/legacy/src/Legacy.Api/Dockerfile @@ -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"] diff --git a/legacy/src/Legacy.Api/Endpoints/AanvragenEndpoints.cs b/legacy/src/Legacy.Api/Endpoints/AanvragenEndpoints.cs new file mode 100644 index 0000000..84717ee --- /dev/null +++ b/legacy/src/Legacy.Api/Endpoints/AanvragenEndpoints.cs @@ -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>> 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, 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, NotFound, Conflict>> 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>> 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> 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(); + } +} diff --git a/legacy/src/Legacy.Api/Endpoints/AanvragenModels.cs b/legacy/src/Legacy.Api/Endpoints/AanvragenModels.cs new file mode 100644 index 0000000..021e6ed --- /dev/null +++ b/legacy/src/Legacy.Api/Endpoints/AanvragenModels.cs @@ -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 Errors); + +public record MigratedResponse(string Message); diff --git a/legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs b/legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs new file mode 100644 index 0000000..008fb10 --- /dev/null +++ b/legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; + +namespace Legacy.Api.Endpoints; + +/// +/// Validates the "gegevens" (particulars) command against the legacy field +/// rules. Collects every violation instead of stopping at the first one. +/// +public static partial class GegevensValidator +{ + public static List Validate(GegevensInput input) + { + var errors = new List(); + + 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(); +} diff --git a/legacy/src/Legacy.Api/Legacy.Api.csproj b/legacy/src/Legacy.Api/Legacy.Api.csproj new file mode 100644 index 0000000..e3006b8 --- /dev/null +++ b/legacy/src/Legacy.Api/Legacy.Api.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.Designer.cs b/legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.Designer.cs new file mode 100644 index 0000000..d53ffd5 --- /dev/null +++ b/legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.Designer.cs @@ -0,0 +1,147 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("AANVR_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdresNr") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("ADRES_NR"); + + b.Property("AdresPc") + .HasColumnType("char(6)") + .HasColumnName("ADRES_PC"); + + b.Property("AdresPl") + .HasMaxLength(60) + .HasColumnType("nvarchar(60)") + .HasColumnName("ADRES_PL"); + + b.Property("AdresStr") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)") + .HasColumnName("ADRES_STR"); + + b.Property("BeoordMotiv") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("BEOORD_MOTIV"); + + b.Property("BeoordRes") + .HasColumnType("char(1)") + .HasColumnName("BEOORD_RES"); + + b.Property("Bsn") + .IsRequired() + .HasColumnType("char(9)") + .HasColumnName("BSN"); + + b.Property("CorrKanaal") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("char(1)") + .HasDefaultValue("P") + .HasColumnName("CORR_KANAAL"); + + b.Property("DatBeoord") + .HasColumnType("date") + .HasColumnName("DAT_BEOORD"); + + b.Property("DatOntv") + .HasColumnType("date") + .HasColumnName("DAT_ONTV"); + + b.Property("DiplCd") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("DIPL_CD"); + + b.Property("DiplDat") + .HasColumnType("date") + .HasColumnName("DIPL_DAT"); + + b.Property("DiplLand") + .HasColumnType("char(2)") + .HasColumnName("DIPL_LAND"); + + b.Property("Email") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)") + .HasColumnName("EMAIL"); + + b.Property("Migrated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("MIGRATED"); + + b.Property("MutDat") + .HasColumnType("datetime2") + .HasColumnName("MUT_DAT"); + + b.Property("MutUser") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)") + .HasColumnName("MUT_USER"); + + b.Property("Naam") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)") + .HasColumnName("NAAM"); + + b.Property("StatCd") + .IsRequired() + .HasColumnType("char(1)") + .HasColumnName("STAT_CD"); + + b.Property("Telnr") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("TELNR"); + + b.Property("Voorl") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("VOORL"); + + b.HasKey("Id"); + + b.ToTable("AANVR", "dbo"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.cs b/legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.cs new file mode 100644 index 0000000..d0d67eb --- /dev/null +++ b/legacy/src/Legacy.Api/Migrations/20260730160055_InitialCreate.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Legacy.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "dbo"); + + migrationBuilder.CreateTable( + name: "AANVR", + schema: "dbo", + columns: table => new + { + AANVR_ID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BSN = table.Column(type: "char(9)", nullable: false), + NAAM = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: false), + VOORL = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + ADRES_STR = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: true), + ADRES_NR = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + ADRES_PC = table.Column(type: "char(6)", nullable: true), + ADRES_PL = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: true), + EMAIL = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: true), + TELNR = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + CORR_KANAAL = table.Column(type: "char(1)", nullable: false, defaultValue: "P"), + STAT_CD = table.Column(type: "char(1)", nullable: false), + DIPL_CD = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + DIPL_LAND = table.Column(type: "char(2)", nullable: true), + DIPL_DAT = table.Column(type: "date", nullable: true), + DAT_ONTV = table.Column(type: "date", nullable: false), + DAT_BEOORD = table.Column(type: "date", nullable: true), + BEOORD_RES = table.Column(type: "char(1)", nullable: true), + BEOORD_MOTIV = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + MIGRATED = table.Column(type: "bit", nullable: false, defaultValue: false), + MUT_DAT = table.Column(type: "datetime2", nullable: false), + MUT_USER = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AANVR", x => x.AANVR_ID); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AANVR", + schema: "dbo"); + } + } +} diff --git a/legacy/src/Legacy.Api/Migrations/LegacyDbContextModelSnapshot.cs b/legacy/src/Legacy.Api/Migrations/LegacyDbContextModelSnapshot.cs new file mode 100644 index 0000000..6406cc2 --- /dev/null +++ b/legacy/src/Legacy.Api/Migrations/LegacyDbContextModelSnapshot.cs @@ -0,0 +1,144 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("AANVR_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdresNr") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("ADRES_NR"); + + b.Property("AdresPc") + .HasColumnType("char(6)") + .HasColumnName("ADRES_PC"); + + b.Property("AdresPl") + .HasMaxLength(60) + .HasColumnType("nvarchar(60)") + .HasColumnName("ADRES_PL"); + + b.Property("AdresStr") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)") + .HasColumnName("ADRES_STR"); + + b.Property("BeoordMotiv") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("BEOORD_MOTIV"); + + b.Property("BeoordRes") + .HasColumnType("char(1)") + .HasColumnName("BEOORD_RES"); + + b.Property("Bsn") + .IsRequired() + .HasColumnType("char(9)") + .HasColumnName("BSN"); + + b.Property("CorrKanaal") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("char(1)") + .HasDefaultValue("P") + .HasColumnName("CORR_KANAAL"); + + b.Property("DatBeoord") + .HasColumnType("date") + .HasColumnName("DAT_BEOORD"); + + b.Property("DatOntv") + .HasColumnType("date") + .HasColumnName("DAT_ONTV"); + + b.Property("DiplCd") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("DIPL_CD"); + + b.Property("DiplDat") + .HasColumnType("date") + .HasColumnName("DIPL_DAT"); + + b.Property("DiplLand") + .HasColumnType("char(2)") + .HasColumnName("DIPL_LAND"); + + b.Property("Email") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)") + .HasColumnName("EMAIL"); + + b.Property("Migrated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("MIGRATED"); + + b.Property("MutDat") + .HasColumnType("datetime2") + .HasColumnName("MUT_DAT"); + + b.Property("MutUser") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)") + .HasColumnName("MUT_USER"); + + b.Property("Naam") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)") + .HasColumnName("NAAM"); + + b.Property("StatCd") + .IsRequired() + .HasColumnType("char(1)") + .HasColumnName("STAT_CD"); + + b.Property("Telnr") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("TELNR"); + + b.Property("Voorl") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("VOORL"); + + b.HasKey("Id"); + + b.ToTable("AANVR", "dbo"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/legacy/src/Legacy.Api/Program.cs b/legacy/src/Legacy.Api/Program.cs new file mode 100644 index 0000000..8a1b06f --- /dev/null +++ b/legacy/src/Legacy.Api/Program.cs @@ -0,0 +1,21 @@ +using Legacy.Api.Data; +using Legacy.Api.Endpoints; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("Legacy"))); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + await LegacySeeder.SeedAsync(db); +} + +app.MapAanvragenEndpoints(); + +app.Run(); diff --git a/legacy/src/Legacy.Web/AanvraagDto.cs b/legacy/src/Legacy.Web/AanvraagDto.cs new file mode 100644 index 0000000..6bda058 --- /dev/null +++ b/legacy/src/Legacy.Web/AanvraagDto.cs @@ -0,0 +1,31 @@ +namespace Legacy.Web; + +/// +/// Mirrors the JSON shape returned by Legacy.Api - legacy column vocabulary, +/// camelCase over the wire, matched here case-insensitively. +/// +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; } = ""; +} diff --git a/legacy/src/Legacy.Web/Dockerfile b/legacy/src/Legacy.Web/Dockerfile new file mode 100644 index 0000000..1af0418 --- /dev/null +++ b/legacy/src/Legacy.Web/Dockerfile @@ -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"] diff --git a/legacy/src/Legacy.Web/Legacy.Web.csproj b/legacy/src/Legacy.Web/Legacy.Web.csproj new file mode 100644 index 0000000..d850a6f --- /dev/null +++ b/legacy/src/Legacy.Web/Legacy.Web.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + true + + + diff --git a/legacy/src/Legacy.Web/LegacyApiClient.cs b/legacy/src/Legacy.Web/LegacyApiClient.cs new file mode 100644 index 0000000..95e4723 --- /dev/null +++ b/legacy/src/Legacy.Web/LegacyApiClient.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Legacy.Web; + +public enum BeoordelingUitkomst +{ + Success, + Conflict, +} + +/// +/// 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. +/// +public class LegacyApiClient(HttpClient httpClient) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> GetAllAsync(string? zoek, string? status) + { + var query = new List(); + 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>(url, JsonOptions); + return result ?? []; + } + + public async Task 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(JsonOptions); + } + + public async Task 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; + } +} diff --git a/legacy/src/Legacy.Web/Pages/Beoordeling.cshtml b/legacy/src/Legacy.Web/Pages/Beoordeling.cshtml new file mode 100644 index 0000000..71c8120 --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/Beoordeling.cshtml @@ -0,0 +1,58 @@ +@page "/legacy/aanvraag/{id:int}/beoordeling" +@model Legacy.Web.Pages.BeoordelingModel + +

Beoordeling aanvraag #@Model.Aanvraag.Id

+ +@if (Model.Ingediend) +{ +
+

De beoordeling is verwerkt.

+

Terug naar aanvraag

+
+} +else if (Model.Aanvraag.Migrated) +{ +
+

Deze aanvraag wordt beheerd in het nieuwe portaal.

+

Naar nieuw portaal

+
+} +else +{ + @if (Model.Conflict) + { +
+

Deze aanvraag wordt inmiddels beheerd in het nieuwe portaal. De beoordeling is niet opgeslagen.

+

Naar nieuw portaal

+
+ } + else + { +
+ Aanvraaggegevens +

@Model.Aanvraag.Naam @Model.Aanvraag.Voorl

+

@Model.Aanvraag.Bsn

+

@Model.Aanvraag.DatOntv.ToString("dd-MM-yyyy")

+
+ +
+
+ Beoordeling +

+ + +

+

+
+ +

+

+ +

+
+
+ } +} diff --git a/legacy/src/Legacy.Web/Pages/Beoordeling.cshtml.cs b/legacy/src/Legacy.Web/Pages/Beoordeling.cshtml.cs new file mode 100644 index 0000000..92918a8 --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/Beoordeling.cshtml.cs @@ -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 OnGetAsync(int id) + { + var aanvraag = await client.GetByIdAsync(id); + if (aanvraag is null) + { + return NotFound(); + } + + Aanvraag = aanvraag; + return Page(); + } + + public async Task 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(); + } +} diff --git a/legacy/src/Legacy.Web/Pages/Index.cshtml b/legacy/src/Legacy.Web/Pages/Index.cshtml new file mode 100644 index 0000000..4e6a4fe --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/Index.cshtml @@ -0,0 +1,59 @@ +@page "/legacy" +@model Legacy.Web.Pages.IndexModel + +

Aanvragen diplomawaardering

+ +
+ + +   + + +   + +
+ +
+ + + + + + + + + + + + + + + @foreach (var a in Model.Aanvragen) + { + + + + + + + + + + } + +
IDBSNNaamStatusOntvangenKanaalActies
@a.Id@a.Bsn@a.Naam @a.Voorl@Legacy.Web.Pages.IndexModel.StatusLabel(a.StatCd)@a.DatOntv.ToString("dd-MM-yyyy")@(a.CorrKanaal == "E" ? "e-mail" : "post") + @if (a.Migrated) + { + beheerd in nieuw portaal + } + else + { + Beoordelen + } +
diff --git a/legacy/src/Legacy.Web/Pages/Index.cshtml.cs b/legacy/src/Legacy.Web/Pages/Index.cshtml.cs new file mode 100644 index 0000000..eae23c1 --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/Index.cshtml.cs @@ -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 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, + }; +} diff --git a/legacy/src/Legacy.Web/Pages/Shared/_Layout.cshtml b/legacy/src/Legacy.Web/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..73b1e7b --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/Shared/_Layout.cshtml @@ -0,0 +1,88 @@ + + + + + Diplomawaardering - Legacy systeem + + + + +
+ @RenderBody() +
+ + diff --git a/legacy/src/Legacy.Web/Pages/_ViewImports.cshtml b/legacy/src/Legacy.Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..0332b49 --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@namespace Legacy.Web.Pages +@using Legacy.Web +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/legacy/src/Legacy.Web/Pages/_ViewStart.cshtml b/legacy/src/Legacy.Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/legacy/src/Legacy.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/legacy/src/Legacy.Web/Program.cs b/legacy/src/Legacy.Web/Program.cs new file mode 100644 index 0000000..dc9188d --- /dev/null +++ b/legacy/src/Legacy.Web/Program.cs @@ -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(client => +{ + client.BaseAddress = new Uri(legacyApiBaseUrl); +}); + +var app = builder.Build(); + +app.MapRazorPages(); + +app.Run(); diff --git a/new-frontend/Dockerfile b/new-frontend/Dockerfile new file mode 100644 index 0000000..5ebe438 --- /dev/null +++ b/new-frontend/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY index.html /usr/share/nginx/html/index.html +EXPOSE 80 diff --git a/new-frontend/index.html b/new-frontend/index.html new file mode 100644 index 0000000..f7396cb --- /dev/null +++ b/new-frontend/index.html @@ -0,0 +1,157 @@ + + + + +Behandel portaal (placeholder) + + +

Behandel portaal — werkvoorraad (placeholder, session 2 replaces this with Angular)

+

+ + + + +

+ + + + + +
OriginReferentieNaamBSNOntvangenUitkomstProcesstatus
+ +

Detail

+
Kies een rij (klik erop) om details te laden.
+
+ + + + diff --git a/new/Dockerfile b/new/Dockerfile new file mode 100644 index 0000000..aaeac02 --- /dev/null +++ b/new/Dockerfile @@ -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"] diff --git a/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs b/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs new file mode 100644 index 0000000..cc63f27 --- /dev/null +++ b/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs @@ -0,0 +1,54 @@ +using New.Application.Worklist; + +namespace New.Api.Contracts; + +/// +/// 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. +/// +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 { ["aanvrager"] = "legacy-backend", ["procestijdlijn"] = null } + : new Dictionary { ["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")); +} diff --git a/new/src/New.Api/Contracts/RequestContracts.cs b/new/src/New.Api/Contracts/RequestContracts.cs new file mode 100644 index 0000000..c23c086 --- /dev/null +++ b/new/src/New.Api/Contracts/RequestContracts.cs @@ -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 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 Errors) +{ + public static ErrorsResponse From(IReadOnlyList 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); diff --git a/new/src/New.Api/Contracts/WorklistContracts.cs b/new/src/New.Api/Contracts/WorklistContracts.cs new file mode 100644 index 0000000..72e53cc --- /dev/null +++ b/new/src/New.Api/Contracts/WorklistContracts.cs @@ -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 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 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 Seams); diff --git a/new/src/New.Api/Endpoints/AssessmentEndpoints.cs b/new/src/New.Api/Endpoints/AssessmentEndpoints.cs new file mode 100644 index 0000000..6637fb6 --- /dev/null +++ b/new/src/New.Api/Endpoints/AssessmentEndpoints.cs @@ -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 RecordAssessmentAsync( + Guid registrationApplicationId, RecordAssessmentRequest request, RecordOwnedAssessmentHandler handler, CancellationToken ct) + { + AssessmentOutcome outcome; + try + { + outcome = Enum.Parse(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), + }; + } +} diff --git a/new/src/New.Api/Endpoints/DetailsEndpoints.cs b/new/src/New.Api/Endpoints/DetailsEndpoints.cs new file mode 100644 index 0000000..cbaa215 --- /dev/null +++ b/new/src/New.Api/Endpoints/DetailsEndpoints.cs @@ -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 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 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), + }; + } +} diff --git a/new/src/New.Api/Endpoints/DiagnosticsEndpoints.cs b/new/src/New.Api/Endpoints/DiagnosticsEndpoints.cs new file mode 100644 index 0000000..b382e73 --- /dev/null +++ b/new/src/New.Api/Endpoints/DiagnosticsEndpoints.cs @@ -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 })); + } +} diff --git a/new/src/New.Api/Endpoints/OwnershipEndpoints.cs b/new/src/New.Api/Endpoints/OwnershipEndpoints.cs new file mode 100644 index 0000000..7e1d2b3 --- /dev/null +++ b/new/src/New.Api/Endpoints/OwnershipEndpoints.cs @@ -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 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 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), + }; + } +} diff --git a/new/src/New.Api/Endpoints/WorklistEndpoints.cs b/new/src/New.Api/Endpoints/WorklistEndpoints.cs new file mode 100644 index 0000000..944a030 --- /dev/null +++ b/new/src/New.Api/Endpoints/WorklistEndpoints.cs @@ -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 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(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 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 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)); + } +} diff --git a/new/src/New.Api/New.Api.csproj b/new/src/New.Api/New.Api.csproj new file mode 100644 index 0000000..aae64e1 --- /dev/null +++ b/new/src/New.Api/New.Api.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + true + New.Api + new-api + + + + + + + + + + + + diff --git a/new/src/New.Api/Program.cs b/new/src/New.Api/Program.cs new file mode 100644 index 0000000..373e68c --- /dev/null +++ b/new/src/New.Api/Program.cs @@ -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(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +await OwnedApplicationSeeder.SeedAsync(app.Services); + +app.MapWorklistEndpoints(); +app.MapDetailsEndpoints(); +app.MapOwnershipEndpoints(); +app.MapAssessmentEndpoints(); +app.MapDiagnosticsEndpoints(); + +app.Run(); diff --git a/new/src/New.Api/Resolution/ApplicationSourceResolver.cs b/new/src/New.Api/Resolution/ApplicationSourceResolver.cs new file mode 100644 index 0000000..7d10e7e --- /dev/null +++ b/new/src/New.Api/Resolution/ApplicationSourceResolver.cs @@ -0,0 +1,33 @@ +using New.Application.Ports; +using New.Application.Worklist; +using New.Infrastructure.Legacy; +using New.Infrastructure.Persistence; + +namespace New.Api.Resolution; + +/// +/// The ONLY type in the whole solution that references both +/// and - +/// 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. +/// +internal sealed class ApplicationSourceResolver( + OwnedApplicationSource owned, + LegacyCaseSource legacy, + IOwnershipRegistry registry) : IApplicationSource +{ + public async Task 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 + } +} diff --git a/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs b/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs new file mode 100644 index 0000000..71b249f --- /dev/null +++ b/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs @@ -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; + +/// +/// 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). +/// +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(); + var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("OwnedApplicationSeeder"); + + if (await db.RegistrationApplications.AnyAsync(ct)) + { + return; + } + + var repository = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + var caseFramework = scope.ServiceProvider.GetRequiredService(); + + 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 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); + } + } + } +} diff --git a/new/src/New.Application/Assessments/RecordAssessmentCommand.cs b/new/src/New.Application/Assessments/RecordAssessmentCommand.cs new file mode 100644 index 0000000..b40d35a --- /dev/null +++ b/new/src/New.Application/Assessments/RecordAssessmentCommand.cs @@ -0,0 +1,10 @@ +using New.Domain.ValueObjects; + +namespace New.Application.Assessments; + +public sealed record RecordAssessmentCommand( + IReadOnlyList VerifiedItems, + string? ExceptionReason, + AssessmentOutcome Outcome, + string? RejectionCategory, + string Motivation); diff --git a/new/src/New.Application/Assessments/RecordAssessmentResult.cs b/new/src/New.Application/Assessments/RecordAssessmentResult.cs new file mode 100644 index 0000000..08104f9 --- /dev/null +++ b/new/src/New.Application/Assessments/RecordAssessmentResult.cs @@ -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); +} diff --git a/new/src/New.Application/Assessments/RecordOwnedAssessmentHandler.cs b/new/src/New.Application/Assessments/RecordOwnedAssessmentHandler.cs new file mode 100644 index 0000000..91e52e6 --- /dev/null +++ b/new/src/New.Application/Assessments/RecordOwnedAssessmentHandler.cs @@ -0,0 +1,58 @@ +using New.Application.Ports; +using New.Domain; + +namespace New.Application.Assessments; + +/// +/// 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. +/// +public sealed class RecordOwnedAssessmentHandler( + IRegistrationApplicationRepository repository, + IUnitOfWork unitOfWork, + IOwnershipRegistry registry, + ICaseFrameworkGateway caseFrameworkGateway, + TimeProvider clock) +{ + public async Task 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); + } +} diff --git a/new/src/New.Application/New.Application.csproj b/new/src/New.Application/New.Application.csproj new file mode 100644 index 0000000..fe5d3f3 --- /dev/null +++ b/new/src/New.Application/New.Application.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + true + New.Application + + + + + + + + + + + + + + diff --git a/new/src/New.Application/Ownership/ReleaseOwnershipHandler.cs b/new/src/New.Application/Ownership/ReleaseOwnershipHandler.cs new file mode 100644 index 0000000..c1ad183 --- /dev/null +++ b/new/src/New.Application/Ownership/ReleaseOwnershipHandler.cs @@ -0,0 +1,45 @@ +using New.Application.Ports; + +namespace New.Application.Ownership; + +/// +/// 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. +/// +public sealed class ReleaseOwnershipHandler( + IOwnershipRegistry registry, + ILegacyCaseGateway legacyGateway, + IRegistrationApplicationRepository repository, + IUnitOfWork unitOfWork) +{ + public async Task 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; + } +} diff --git a/new/src/New.Application/Ownership/ReleaseOwnershipResult.cs b/new/src/New.Application/Ownership/ReleaseOwnershipResult.cs new file mode 100644 index 0000000..62e4efc --- /dev/null +++ b/new/src/New.Application/Ownership/ReleaseOwnershipResult.cs @@ -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); +} diff --git a/new/src/New.Application/Ownership/TakeOwnershipHandler.cs b/new/src/New.Application/Ownership/TakeOwnershipHandler.cs new file mode 100644 index 0000000..40c121c --- /dev/null +++ b/new/src/New.Application/Ownership/TakeOwnershipHandler.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; +using New.Application.Ports; +using New.Domain; +using New.Domain.ValueObjects; + +namespace New.Application.Ownership; + +/// +/// 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. +/// +public sealed class TakeOwnershipHandler( + IOwnershipRegistry registry, + ILegacyCaseGateway legacyGateway, + ICaseFrameworkGateway caseFrameworkGateway, + IRegistrationApplicationRepository repository, + IUnitOfWork unitOfWork, + TimeProvider clock, + ILogger logger) +{ + private const string CaseTypeCode = "RegistrationApplication"; + + public async Task 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); + } +} diff --git a/new/src/New.Application/Ownership/TakeOwnershipResult.cs b/new/src/New.Application/Ownership/TakeOwnershipResult.cs new file mode 100644 index 0000000..ee6c9b3 --- /dev/null +++ b/new/src/New.Application/Ownership/TakeOwnershipResult.cs @@ -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); +} diff --git a/new/src/New.Application/Ports/IApplicationSource.cs b/new/src/New.Application/Ports/IApplicationSource.cs new file mode 100644 index 0000000..a59e6fa --- /dev/null +++ b/new/src/New.Application/Ports/IApplicationSource.cs @@ -0,0 +1,14 @@ +using New.Application.Worklist; + +namespace New.Application.Ports; + +/// +/// 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). +/// +public interface IApplicationSource +{ + Task GetByLegacyIdAsync(int aanvraagId, CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/ICaseFrameworkGateway.cs b/new/src/New.Application/Ports/ICaseFrameworkGateway.cs new file mode 100644 index 0000000..96282d4 --- /dev/null +++ b/new/src/New.Application/Ports/ICaseFrameworkGateway.cs @@ -0,0 +1,25 @@ +namespace New.Application.Ports; + +public sealed record CaseCreated(Guid CaseId, string? ProcessStatus); + +public sealed record TaskCreated(Guid TaskId, bool Open); + +/// Seam D: the case-framework client port (New.Infrastructure.CaseFramework implements this). +public interface ICaseFrameworkGateway +{ + Task CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList participants, CancellationToken ct); + + Task GetProcessStatusAsync(Guid caseId, CancellationToken ct); + + Task CreateTaskAsync(Guid caseId, string code, string description, CancellationToken ct); + + Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct); + + /// + /// 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). + /// + Task RequestClosureAsync(Guid caseId, CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/ILegacyCaseGateway.cs b/new/src/New.Application/Ports/ILegacyCaseGateway.cs new file mode 100644 index 0000000..cceb5d8 --- /dev/null +++ b/new/src/New.Application/Ports/ILegacyCaseGateway.cs @@ -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); + +/// +/// The legacy-facing operations needed by the take-ownership flow, the +/// write-through edit seam, and ownership release - as opposed to +/// LegacyCaseSource (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). +/// +public interface ILegacyCaseGateway +{ + /// + /// Fetches the legacy case and maps it to a + /// 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. + /// + Task FetchAndMapAsync(int aanvraagId, CancellationToken ct); + + /// + /// Seam B: PUT .../gegevens. This is a pure translation - see + /// LegacyDetailsWriteThroughTranslator for the "no business rules" comment. + /// + Task UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct); + + /// PUT .../migratie-vlag. Used on take-ownership (true) and release-ownership (false). + Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/ILegacyWorklistReader.cs b/new/src/New.Application/Ports/ILegacyWorklistReader.cs new file mode 100644 index 0000000..c241784 --- /dev/null +++ b/new/src/New.Application/Ports/ILegacyWorklistReader.cs @@ -0,0 +1,9 @@ +using New.Application.Worklist; + +namespace New.Application.Ports; + +/// Lists legacy applications (via seam A) for the merged worklist. +public interface ILegacyWorklistReader +{ + Task> ListAsync(CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/IOwnedWorklistReader.cs b/new/src/New.Application/Ports/IOwnedWorklistReader.cs new file mode 100644 index 0000000..9eb6cb1 --- /dev/null +++ b/new/src/New.Application/Ports/IOwnedWorklistReader.cs @@ -0,0 +1,14 @@ +using New.Application.Worklist; + +namespace New.Application.Ports; + +/// +/// 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. +/// +public interface IOwnedWorklistReader +{ + Task> ListAsync(CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/IOwnershipRegistry.cs b/new/src/New.Application/Ports/IOwnershipRegistry.cs new file mode 100644 index 0000000..9fcecfa --- /dev/null +++ b/new/src/New.Application/Ports/IOwnershipRegistry.cs @@ -0,0 +1,30 @@ +namespace New.Application.Ports; + +/// Read-model row of the `legacy_ownership` table. +public sealed record OwnershipRecord( + int LegacyAanvraagId, + Guid RegistrationApplicationId, + DateTimeOffset TakenOverAt, + int DomainWritesSince); + +/// +/// 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). +/// +public interface IOwnershipRegistry +{ + /// Null if has not been taken into ownership. + Task LookupOwnedIdAsync(int legacyAanvraagId, CancellationToken ct); + + Task GetAsync(Guid registrationApplicationId, CancellationToken ct); + + /// Stages a new ownership row (flushed by ). + Task RecordAsync(int legacyAanvraagId, Guid registrationApplicationId, DateTimeOffset takenOverAt, CancellationToken ct); + + /// Increments `domain_writes_since` for a domain write against an adopted aggregate. + Task IncrementDomainWritesAsync(Guid registrationApplicationId, CancellationToken ct); + + /// Stages removal of the ownership row (flushed by ). + Task RemoveAsync(Guid registrationApplicationId, CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/IRegistrationApplicationRepository.cs b/new/src/New.Application/Ports/IRegistrationApplicationRepository.cs new file mode 100644 index 0000000..33c9c2a --- /dev/null +++ b/new/src/New.Application/Ports/IRegistrationApplicationRepository.cs @@ -0,0 +1,15 @@ +using New.Domain; + +namespace New.Application.Ports; + +/// Owned-side persistence port for the aggregate. +public interface IRegistrationApplicationRepository +{ + Task GetAsync(Guid registrationApplicationId, CancellationToken ct); + + /// Stages a brand-new aggregate for insertion (flushed on the next ). + Task AddAsync(RegistrationApplication application, CancellationToken ct); + + /// Stages an aggregate for deletion (flushed on the next ). + Task RemoveAsync(RegistrationApplication application, CancellationToken ct); +} diff --git a/new/src/New.Application/Ports/IUnitOfWork.cs b/new/src/New.Application/Ports/IUnitOfWork.cs new file mode 100644 index 0000000..271c1a1 --- /dev/null +++ b/new/src/New.Application/Ports/IUnitOfWork.cs @@ -0,0 +1,12 @@ +namespace New.Application.Ports; + +/// +/// Commits everything staged through +/// and 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. +/// +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/new/src/New.Application/Worklist/AddressData.cs b/new/src/New.Application/Worklist/AddressData.cs new file mode 100644 index 0000000..7bf334e --- /dev/null +++ b/new/src/New.Application/Worklist/AddressData.cs @@ -0,0 +1,10 @@ +namespace New.Application.Worklist; + +/// +/// 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). +/// +public sealed record AddressData(string Street, string Number, string PostalCode, string City); diff --git a/new/src/New.Application/Worklist/AssessmentData.cs b/new/src/New.Application/Worklist/AssessmentData.cs new file mode 100644 index 0000000..222d23e --- /dev/null +++ b/new/src/New.Application/Worklist/AssessmentData.cs @@ -0,0 +1,10 @@ +namespace New.Application.Worklist; + +/// Read-model projection of a recorded assessment, for display purposes. +public sealed record AssessmentData( + string Outcome, + string Motivation, + IReadOnlyList VerifiedItems, + string? ExceptionReason, + string? RejectionCategory, + DateOnly DecidedOn); diff --git a/new/src/New.Application/Worklist/CaseDetail.cs b/new/src/New.Application/Worklist/CaseDetail.cs new file mode 100644 index 0000000..7620df0 --- /dev/null +++ b/new/src/New.Application/Worklist/CaseDetail.cs @@ -0,0 +1,34 @@ +namespace New.Application.Worklist; + +/// +/// 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 . +/// +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, + /// + /// 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. + /// + DateTimeOffset? LastModifiedAt = null); diff --git a/new/src/New.Application/Worklist/WorklistItem.cs b/new/src/New.Application/Worklist/WorklistItem.cs new file mode 100644 index 0000000..5251c11 --- /dev/null +++ b/new/src/New.Application/Worklist/WorklistItem.cs @@ -0,0 +1,27 @@ +namespace New.Application.Worklist; + +/// +/// 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. +/// +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, + /// + /// 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. + /// + bool Migrated = false); diff --git a/new/src/New.Application/Worklist/WorklistOrigin.cs b/new/src/New.Application/Worklist/WorklistOrigin.cs new file mode 100644 index 0000000..e5d76e9 --- /dev/null +++ b/new/src/New.Application/Worklist/WorklistOrigin.cs @@ -0,0 +1,8 @@ +namespace New.Application.Worklist; + +/// Which of the two sources a worklist item or case detail came from. +public enum WorklistOrigin +{ + Legacy, + Owned, +} diff --git a/new/src/New.Application/WriteThrough/ApplicantDetailsCommand.cs b/new/src/New.Application/WriteThrough/ApplicantDetailsCommand.cs new file mode 100644 index 0000000..1ea14be --- /dev/null +++ b/new/src/New.Application/WriteThrough/ApplicantDetailsCommand.cs @@ -0,0 +1,16 @@ +using New.Application.Worklist; + +namespace New.Application.WriteThrough; + +/// +/// 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). +/// +public sealed record ApplicantDetailsCommand( + string Surname, + string Initials, + AddressData? Address, + string? Email, + string? Phone, + string PreferredChannel); diff --git a/new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsHandler.cs b/new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsHandler.cs new file mode 100644 index 0000000..4fffcd0 --- /dev/null +++ b/new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsHandler.cs @@ -0,0 +1,60 @@ +using New.Application.Ports; +using New.Domain; +using New.Domain.ValueObjects; + +namespace New.Application.WriteThrough; + +/// +/// 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). +/// +public sealed class UpdateOwnedApplicantDetailsHandler( + IRegistrationApplicationRepository repository, + IUnitOfWork unitOfWork, + IOwnershipRegistry registry) +{ + public async Task 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')."), + }; +} diff --git a/new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsResult.cs b/new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsResult.cs new file mode 100644 index 0000000..748f99c --- /dev/null +++ b/new/src/New.Application/WriteThrough/UpdateOwnedApplicantDetailsResult.cs @@ -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); +} diff --git a/new/src/New.Application/WriteThrough/WriteThroughResult.cs b/new/src/New.Application/WriteThrough/WriteThroughResult.cs new file mode 100644 index 0000000..5ea52b3 --- /dev/null +++ b/new/src/New.Application/WriteThrough/WriteThroughResult.cs @@ -0,0 +1,28 @@ +namespace New.Application.WriteThrough; + +/// +/// A single portal-shaped field error, after the write-through translator has +/// mapped a legacy `veld`/`code`/`melding` triple. 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. +/// +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? 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 errors) => + new(WriteThroughOutcomeKind.ValidationFailed, errors); +} diff --git a/new/src/New.Domain/DomainInvariantViolationException.cs b/new/src/New.Domain/DomainInvariantViolationException.cs new file mode 100644 index 0000000..4aa193d --- /dev/null +++ b/new/src/New.Domain/DomainInvariantViolationException.cs @@ -0,0 +1,22 @@ +namespace New.Domain; + +/// +/// Thrown whenever a domain invariant is violated - by native creation of a +/// , or by the legacy mapper feeding data +/// through the same validating constructors/factories during adoption. +/// +/// 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 . +/// +public sealed class DomainInvariantViolationException : Exception +{ + public string Invariant { get; } + + public DomainInvariantViolationException(string invariant, string message) + : base(message) + { + Invariant = invariant; + } +} diff --git a/new/src/New.Domain/New.Domain.csproj b/new/src/New.Domain/New.Domain.csproj new file mode 100644 index 0000000..824621d --- /dev/null +++ b/new/src/New.Domain/New.Domain.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + true + New.Domain + + + + + diff --git a/new/src/New.Domain/RegistrationApplication.cs b/new/src/New.Domain/RegistrationApplication.cs new file mode 100644 index 0000000..7a89777 --- /dev/null +++ b/new/src/New.Domain/RegistrationApplication.cs @@ -0,0 +1,186 @@ +using New.Domain.ValueObjects; + +namespace New.Domain; + +/// +/// 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). +/// +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; } + + /// + /// 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 (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. fills it in + /// immediately after, before anything is persisted. + /// + 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; + } + + /// + /// Creates a new application. Used both for genuinely native creation and + /// by the legacy mapper during adoption (with + /// left null, attached afterwards via ). + /// + 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); + } + + /// + /// 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 validation. + /// + 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; + } + + /// + /// Attaches this aggregate to its case-framework case. Callable exactly + /// once - see the class remarks on for why this exists + /// as a separate step instead of a constructor parameter. + /// + 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; + } + + /// Updates the case-framework's own process status mirror. + 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); + } + + /// + /// 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. + /// + public void UpdateApplicantDetails( + PersonName applicant, + Address? correspondenceAddress, + ContactDetails contactDetails) + { + Applicant = applicant; + CorrespondenceAddress = correspondenceAddress; + ContactDetails = contactDetails; + } + + /// + /// Records the outcome of an assessment. All validation lives in + /// - this method's job is purely to apply + /// the result to the aggregate. + /// + public void RecordAssessment( + AssessmentOutcome outcome, + string motivation, + IReadOnlyList? verifiedItems, + string? exceptionReason, + string? rejectionCategory, + DateOnly decidedOn) + { + Assessment = Assessment.Create(outcome, motivation, verifiedItems, exceptionReason, rejectionCategory, decidedOn); + } +} diff --git a/new/src/New.Domain/ValueObjects/Address.cs b/new/src/New.Domain/ValueObjects/Address.cs new file mode 100644 index 0000000..c51ee6a --- /dev/null +++ b/new/src/New.Domain/ValueObjects/Address.cs @@ -0,0 +1,42 @@ +namespace New.Domain.ValueObjects; + +/// +/// 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 at all - +/// see , which is +/// itself nullable for exactly this reason. +/// +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)."); + } + } +} diff --git a/new/src/New.Domain/ValueObjects/Assessment.cs b/new/src/New.Domain/ValueObjects/Assessment.cs new file mode 100644 index 0000000..d8a1a20 --- /dev/null +++ b/new/src/New.Domain/ValueObjects/Assessment.cs @@ -0,0 +1,101 @@ +namespace New.Domain.ValueObjects; + +/// +/// The decision on an application. Named AssessmentOutcome - never the +/// bare word "Status" - to keep it distinct from the case-framework's own +/// ProcessStatus and from the legacy `stat_cd` / `beoordRes` codes. +/// Also stands in for what the case-framework calls a "Decision" document. +/// +public enum AssessmentOutcome +{ + Approved, + Rejected, +} + +/// +/// A recorded assessment of a . Only ever +/// constructed through , 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. +/// +public sealed record Assessment +{ + private const int DefaultMinimumMotivationLength = 20; + private const int OtherCategoryMinimumMotivationLength = 50; + + public AssessmentOutcome Outcome { get; } + public string Motivation { get; } + public IReadOnlyList VerifiedItems { get; } + public string? ExceptionReason { get; } + public string? RejectionCategory { get; } + public DateOnly DecidedOn { get; } + + private Assessment( + AssessmentOutcome outcome, + string motivation, + IReadOnlyList 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? verifiedItems, + string? exceptionReason, + string? rejectionCategory, + DateOnly decidedOn) + { + var items = verifiedItems ?? Array.Empty(); + + // 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)); +} diff --git a/new/src/New.Domain/ValueObjects/Bsn.cs b/new/src/New.Domain/ValueObjects/Bsn.cs new file mode 100644 index 0000000..a3965c1 --- /dev/null +++ b/new/src/New.Domain/ValueObjects/Bsn.cs @@ -0,0 +1,66 @@ +namespace New.Domain.ValueObjects; + +/// +/// 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. +/// +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; + } + + /// + /// (9*d1 + 8*d2 + 7*d3 + 6*d4 + 5*d5 + 4*d6 + 3*d7 + 2*d8 - 1*d9) % 11 == 0 + /// + 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; +} diff --git a/new/src/New.Domain/ValueObjects/CaseReference.cs b/new/src/New.Domain/ValueObjects/CaseReference.cs new file mode 100644 index 0000000..bb4c6fc --- /dev/null +++ b/new/src/New.Domain/ValueObjects/CaseReference.cs @@ -0,0 +1,42 @@ +namespace New.Domain.ValueObjects; + +/// +/// Pure correlation to the case-framework's own case - deliberately NOT +/// inheritance and NOT a base class. +/// has-a , it is not-a case-framework case. +/// +/// 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 decision. +/// +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); +} diff --git a/new/src/New.Domain/ValueObjects/ContactDetails.cs b/new/src/New.Domain/ValueObjects/ContactDetails.cs new file mode 100644 index 0000000..9190fa1 --- /dev/null +++ b/new/src/New.Domain/ValueObjects/ContactDetails.cs @@ -0,0 +1,42 @@ +using System.Text.RegularExpressions; + +namespace New.Domain.ValueObjects; + +/// How the applicant prefers to be contacted. +public enum CorrespondenceChannel +{ + Post, + Email, +} + +/// +/// Email/phone plus the applicant's preferred channel. The one real invariant: +/// choosing requires a non-empty, +/// well-formed email address - you can't ask to be emailed with no email on file. +/// +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); +} diff --git a/new/src/New.Domain/ValueObjects/DiplomaEvidence.cs b/new/src/New.Domain/ValueObjects/DiplomaEvidence.cs new file mode 100644 index 0000000..f6a8022 --- /dev/null +++ b/new/src/New.Domain/ValueObjects/DiplomaEvidence.cs @@ -0,0 +1,30 @@ +namespace New.Domain.ValueObjects; + +/// Evidence of a foreign diploma submitted in support of the application. +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; + } +} diff --git a/new/src/New.Domain/ValueObjects/PersonName.cs b/new/src/New.Domain/ValueObjects/PersonName.cs new file mode 100644 index 0000000..34ce9a0 --- /dev/null +++ b/new/src/New.Domain/ValueObjects/PersonName.cs @@ -0,0 +1,32 @@ +namespace New.Domain.ValueObjects; + +/// +/// The applicant's name. Deliberately called PersonName/Applicant +/// rather than the case-framework's "Participant" - see the false-cognates +/// table in the migration design notes. +/// +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; + } +} diff --git a/new/src/New.Infrastructure.CaseFramework/CaseFrameworkClient.cs b/new/src/New.Infrastructure.CaseFramework/CaseFrameworkClient.cs new file mode 100644 index 0000000..f844569 --- /dev/null +++ b/new/src/New.Infrastructure.CaseFramework/CaseFrameworkClient.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Net.Http.Json; +using New.Infrastructure.CaseFramework.Dtos; + +namespace New.Infrastructure.CaseFramework; + +/// Thin wrapper around the case-framework HttpClient - the one place that knows its exact routes. +internal sealed class CaseFrameworkClient(HttpClient httpClient) +{ + public async Task CreateCaseAsync(CreateCaseRequest request, CancellationToken ct) + { + using var response = await httpClient.PostAsJsonAsync("/cases", request, ct); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(ct))!; + } + + public async Task 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(ct); + } + + public async Task 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(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(); + } + + /// Returns true if the case closed (204), false if the framework returned 409 (an open task). + public async Task 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; + } +} diff --git a/new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs b/new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs new file mode 100644 index 0000000..5cfa2a7 --- /dev/null +++ b/new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs @@ -0,0 +1,40 @@ +using New.Application.Ports; +using New.Infrastructure.CaseFramework.Dtos; + +namespace New.Infrastructure.CaseFramework; + +/// Seam D: implements against the case-framework's own contract. +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 CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList 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 GetProcessStatusAsync(Guid caseId, CancellationToken ct) + { + var response = await _client.GetCaseAsync(caseId, ct); + return response?.ProcessStatus; + } + + public async Task 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 RequestClosureAsync(Guid caseId, CancellationToken ct) => + _client.RequestClosureAsync(caseId, ct); +} diff --git a/new/src/New.Infrastructure.CaseFramework/Dtos/CaseFrameworkDtos.cs b/new/src/New.Infrastructure.CaseFramework/Dtos/CaseFrameworkDtos.cs new file mode 100644 index 0000000..1f68e30 --- /dev/null +++ b/new/src/New.Infrastructure.CaseFramework/Dtos/CaseFrameworkDtos.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace New.Infrastructure.CaseFramework.Dtos; + +/// +/// 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). +/// +internal sealed record CreateCaseRequest( + [property: JsonPropertyName("caseTypeCode")] string CaseTypeCode, + [property: JsonPropertyName("externalReference")] string ExternalReference, + [property: JsonPropertyName("participants")] List 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 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 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); diff --git a/new/src/New.Infrastructure.CaseFramework/New.Infrastructure.CaseFramework.csproj b/new/src/New.Infrastructure.CaseFramework/New.Infrastructure.CaseFramework.csproj new file mode 100644 index 0000000..29b734a --- /dev/null +++ b/new/src/New.Infrastructure.CaseFramework/New.Infrastructure.CaseFramework.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + true + New.Infrastructure.CaseFramework + + + + + + + + + + + + + + diff --git a/new/src/New.Infrastructure.CaseFramework/Options/CaseFrameworkOptions.cs b/new/src/New.Infrastructure.CaseFramework/Options/CaseFrameworkOptions.cs new file mode 100644 index 0000000..0d877e0 --- /dev/null +++ b/new/src/New.Infrastructure.CaseFramework/Options/CaseFrameworkOptions.cs @@ -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; +} diff --git a/new/src/New.Infrastructure.CaseFramework/ServiceCollectionExtensions.cs b/new/src/New.Infrastructure.CaseFramework/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..46aaf4e --- /dev/null +++ b/new/src/New.Infrastructure.CaseFramework/ServiceCollectionExtensions.cs @@ -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; + +/// +/// 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. +/// +public static class ServiceCollectionExtensions +{ + private const string HttpClientName = "CaseFramework"; + + public static IServiceCollection AddCaseFrameworkInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(CaseFrameworkOptions.SectionName)); + + services.AddHttpClient(HttpClientName, (sp, http) => + { + var options = sp.GetRequiredService>().Value; + http.BaseAddress = new Uri(options.BaseUrl); + }); + + services.AddScoped(sp => + new CaseFrameworkClient(sp.GetRequiredService().CreateClient(HttpClientName))); + + services.AddScoped(sp => + new CaseFrameworkGateway(sp.GetRequiredService())); + + return services; + } +} diff --git a/new/src/New.Infrastructure.Legacy/AmsterdamClock.cs b/new/src/New.Infrastructure.Legacy/AmsterdamClock.cs new file mode 100644 index 0000000..b890be6 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/AmsterdamClock.cs @@ -0,0 +1,19 @@ +namespace New.Infrastructure.Legacy; + +/// +/// 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). +/// +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); + } +} diff --git a/new/src/New.Infrastructure.Legacy/Dtos/LegacyAanvraagDto.cs b/new/src/New.Infrastructure.Legacy/Dtos/LegacyAanvraagDto.cs new file mode 100644 index 0000000..744ba8f --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/Dtos/LegacyAanvraagDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace New.Infrastructure.Legacy.Dtos; + +/// +/// 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). +/// +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); diff --git a/new/src/New.Infrastructure.Legacy/Dtos/LegacyWriteThroughDtos.cs b/new/src/New.Infrastructure.Legacy/Dtos/LegacyWriteThroughDtos.cs new file mode 100644 index 0000000..e3a2edc --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/Dtos/LegacyWriteThroughDtos.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace New.Infrastructure.Legacy.Dtos; + +/// Seam B request body - legacy was told to accept exactly this portal-facing shape. +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 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); diff --git a/new/src/New.Infrastructure.Legacy/LegacyAanvraagMapper.cs b/new/src/New.Infrastructure.Legacy/LegacyAanvraagMapper.cs new file mode 100644 index 0000000..b70f56f --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyAanvraagMapper.cs @@ -0,0 +1,132 @@ +using New.Domain; +using New.Domain.ValueObjects; +using New.Infrastructure.Legacy.Dtos; + +namespace New.Infrastructure.Legacy; + +/// +/// Maps a legacy row to a . 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. +/// +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!); + } +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyAanvraagStatus.cs b/new/src/New.Infrastructure.Legacy/LegacyAanvraagStatus.cs new file mode 100644 index 0000000..9e050cc --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyAanvraagStatus.cs @@ -0,0 +1,30 @@ +using New.Domain; + +namespace New.Infrastructure.Legacy; + +/// +/// 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. +/// +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}'."), + }; +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyBackendClient.cs b/new/src/New.Infrastructure.Legacy/LegacyBackendClient.cs new file mode 100644 index 0000000..aec9eb6 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyBackendClient.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Json; +using New.Infrastructure.Legacy.Dtos; + +namespace New.Infrastructure.Legacy; + +/// Thin wrapper around the legacy-backend HttpClient - the one place that knows its exact routes. +internal sealed class LegacyBackendClient(HttpClient httpClient) +{ + public async Task 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(ct); + } + + public async Task> ListAanvragenAsync(CancellationToken ct) + { + var result = await httpClient.GetFromJsonAsync>("/api/aanvragen", ct); + return result ?? []; + } + + public async Task 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(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? 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 errors) => + new(LegacyDetailsWriteOutcome.ValidationFailed, errors); +} + +internal enum LegacyDetailsWriteOutcome +{ + Success, + NotFound, + Conflict, + ValidationFailed, +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyCallCounter.cs b/new/src/New.Infrastructure.Legacy/LegacyCallCounter.cs new file mode 100644 index 0000000..acaba26 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyCallCounter.cs @@ -0,0 +1,26 @@ +namespace New.Infrastructure.Legacy; + +/// +/// In-process counter of actual legacy HTTP calls, backing GET +/// /api/diagnostics/legacy-call-count. Incremented exclusively by +/// - 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. +/// +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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + counter.Increment(); + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyCaseDetailProjection.cs b/new/src/New.Infrastructure.Legacy/LegacyCaseDetailProjection.cs new file mode 100644 index 0000000..af2621c --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyCaseDetailProjection.cs @@ -0,0 +1,76 @@ +using New.Application.Worklist; +using New.Infrastructure.Legacy.Dtos; + +namespace New.Infrastructure.Legacy; + +/// Shared legacy-row -> read-model projection, used by both LegacyCaseSource and LegacyWorklistReader. +internal static class LegacyCaseDetailProjection +{ + public static CaseDetail ToCaseDetail(LegacyAanvraagDto dto) + { + var status = LegacyAanvraagStatusMapper.Parse(dto.StatCd); + + AddressData? address = HasAllFourAddressParts(dto) + ? new AddressData(dto.AdresStr!, dto.AdresNr!, dto.AdresPc!, dto.AdresPl!) + : null; + + AssessmentData? assessment = dto.BeoordRes is not null + ? new AssessmentData( + dto.BeoordRes == "G" ? "Approved" : "Rejected", + dto.BeoordMotiv ?? string.Empty, + VerifiedItems: [], + ExceptionReason: null, + RejectionCategory: null, + dto.DatBeoord ?? dto.DatOntv) + : null; + + var preferredChannel = dto.CorrKanaal == "E" ? "Email" : "Post"; + + return new CaseDetail( + WorklistOrigin.Legacy, + dto.Id, + RegistrationApplicationId: null, + dto.Naam, + dto.Voorl, + dto.Bsn.Trim(), + address, + dto.Email, + dto.Telnr, + preferredChannel, + dto.DiplCd, + dto.DiplLand, + dto.DiplDat, + dto.DatOntv, + assessment, + ProcessStatus: status.ToString(), + CaseFrameworkCaseId: null, + Migrated: dto.Migrated, + LastModifiedAt: AmsterdamClock.ToUtcOffset(dto.MutDat)); + } + + public static WorklistItem ToWorklistItem(LegacyAanvraagDto dto) + { + var status = LegacyAanvraagStatusMapper.Parse(dto.StatCd); + var outcome = dto.BeoordRes switch { "G" => "Approved", "A" => "Rejected", _ => null }; + + return new WorklistItem( + WorklistOrigin.Legacy, + dto.Id, + RegistrationApplicationId: null, + dto.Naam, + dto.Voorl, + dto.Bsn.Trim(), + dto.DatOntv, + Bucket: status.ToString(), + AssessmentOutcome: outcome, + ProcessStatus: status.ToString(), + LastModifiedAt: AmsterdamClock.ToUtcOffset(dto.MutDat), + Migrated: dto.Migrated); + } + + private static bool HasAllFourAddressParts(LegacyAanvraagDto dto) => + !string.IsNullOrWhiteSpace(dto.AdresStr) && + !string.IsNullOrWhiteSpace(dto.AdresNr) && + !string.IsNullOrWhiteSpace(dto.AdresPc) && + !string.IsNullOrWhiteSpace(dto.AdresPl); +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyCaseGateway.cs b/new/src/New.Infrastructure.Legacy/LegacyCaseGateway.cs new file mode 100644 index 0000000..092e9ef --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyCaseGateway.cs @@ -0,0 +1,60 @@ +using New.Application.Ports; +using New.Application.WriteThrough; +using New.Domain; + +namespace New.Infrastructure.Legacy; + +/// +/// Implements : the legacy-facing operations +/// used by take-ownership, the write-through edit seam, and ownership +/// release. Kept separate from (seam A read, +/// used only by the source resolver) - see that class's remarks. +/// +public sealed class LegacyCaseGateway : ILegacyCaseGateway +{ + private readonly LegacyBackendClient _client; + private readonly LegacyDetailsWriteThroughTranslator _translator; + + // Internal constructor: see LegacyCaseSource's remarks. Registered via an + // explicit factory in ServiceCollectionExtensions, not auto-construction. + internal LegacyCaseGateway(LegacyBackendClient client, LegacyDetailsWriteThroughTranslator translator) + { + _client = client; + _translator = translator; + } + + public async Task FetchAndMapAsync(int aanvraagId, CancellationToken ct) + { + var dto = await _client.GetAanvraagAsync(aanvraagId, ct); + if (dto is null) + { + return new LegacyFetchAndMapResult(LegacyFetchStatus.NotFound, null); + } + + // Any DomainInvariantViolationException thrown by the mapper is + // deliberately NOT caught here - it propagates to the caller + // (TakeOwnershipHandler), which is exactly what "nothing is written + // anywhere on a mapping failure" requires: this method only reads. + var application = LegacyAanvraagMapper.ToDomain(dto); + return new LegacyFetchAndMapResult(LegacyFetchStatus.Found, application); + } + + public async Task UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct) + { + var request = _translator.ToLegacyRequest(command); + var response = await _client.UpdateDetailsAsync(aanvraagId, request, ct); + + return response.Outcome switch + { + LegacyDetailsWriteOutcome.Success => WriteThroughOutcome.Success, + LegacyDetailsWriteOutcome.NotFound => WriteThroughOutcome.NotFound, + LegacyDetailsWriteOutcome.Conflict => WriteThroughOutcome.Conflict, + LegacyDetailsWriteOutcome.ValidationFailed => WriteThroughOutcome.ValidationFailed( + _translator.ToPortalErrors(response.Errors ?? [])), + _ => throw new InvalidOperationException($"Unhandled legacy write-through outcome '{response.Outcome}'."), + }; + } + + public Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct) => + _client.SetMigratieVlagAsync(aanvraagId, migrated, ct); +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs b/new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs new file mode 100644 index 0000000..378a3b1 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs @@ -0,0 +1,27 @@ +using New.Application.Worklist; + +namespace New.Infrastructure.Legacy; + +/// +/// Seam A single-case read: GET /api/aanvragen/{id}. Deliberately a concrete +/// class with no interface of its own - like OwnedApplicationSource +/// (New.Infrastructure.Persistence), it exists only to be injected into the +/// source resolver (New.Api), which is the only type allowed to reference +/// both of them (Architecture.Tests rule 7). +/// +public sealed class LegacyCaseSource +{ + private readonly LegacyBackendClient _client; + + // Internal constructor: LegacyBackendClient's own API surface uses the + // internal LegacyAanvraagDto, so it can't be a public constructor + // parameter on this public class. DI can still call an internal + // constructor from another assembly via reflection. + internal LegacyCaseSource(LegacyBackendClient client) => _client = client; + + public async Task GetAsync(int aanvraagId, CancellationToken ct) + { + var dto = await _client.GetAanvraagAsync(aanvraagId, ct); + return dto is null ? null : LegacyCaseDetailProjection.ToCaseDetail(dto); + } +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs b/new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs new file mode 100644 index 0000000..9ad2697 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using New.Application.WriteThrough; +using New.Infrastructure.Legacy.Dtos; + +namespace New.Infrastructure.Legacy; + +/// +/// Seam B's write-through translator (PUT .../gegevens). +/// +/// CRITICAL CONSTRAINT (this becomes ADR-002): this translator must contain +/// NO business rules. No conditionals on request values, no validation +/// beyond null/shape checks, no derived values, no defaulting. Legacy is the +/// sole authority on these rules - every "is this actually valid" decision +/// happens on the other side of the HTTP call, and this class only reshapes +/// the request/response, it never second-guesses them. +/// +internal sealed class LegacyDetailsWriteThroughTranslator(ILogger logger) +{ + private static readonly IReadOnlyDictionary FieldPathsByLegacyVeld = new Dictionary + { + ["NAAM"] = "surname", + ["ADRES_PC"] = "address.postalCode", + ["ADRES_NR"] = "address.number", + ["EMAIL"] = "email", + ["TELNR"] = "phone", + }; + + private static readonly IReadOnlyDictionary MessagesByLegacyCode = new Dictionary + { + // Pure lookup table, not a rule engine - the message text is + // presentation only, the pass/fail decision already happened in legacy. + }; + + public LegacyDetailsWriteRequest ToLegacyRequest(ApplicantDetailsCommand command) => new( + command.Surname, + command.Initials, + command.Address is { } a ? new LegacyAddressWriteRequest(a.Street, a.Number, a.PostalCode, a.City) : null, + command.Email, + command.Phone, + command.PreferredChannel); + + public IReadOnlyList ToPortalErrors(IEnumerable legacyErrors) => + legacyErrors.Select(ToPortalError).ToList(); + + private PortalFieldError ToPortalError(LegacyValidationError error) + { + if (!FieldPathsByLegacyVeld.TryGetValue(error.Veld, out var fieldPath)) + { + // Unrecognized `veld` - never throw/crash, just fall back to a + // generic field path and surface legacy's own message verbatim + // via `detail`, plus a warning so it gets noticed and the lookup + // table above extended. + logger.LogWarning( + "Unrecognized legacy validation veld '{Veld}' (code '{Code}'): {Melding}", + error.Veld, error.Code, error.Melding); + + return new PortalFieldError( + Field: error.Veld, + Message: "This field could not be saved; see detail for the legacy system's message.", + Detail: error.Melding); + } + + var message = MessagesByLegacyCode.TryGetValue(error.Code, out var known) + ? known + : error.Melding; + + return new PortalFieldError(fieldPath, message); + } +} diff --git a/new/src/New.Infrastructure.Legacy/LegacyWorklistReader.cs b/new/src/New.Infrastructure.Legacy/LegacyWorklistReader.cs new file mode 100644 index 0000000..af02106 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/LegacyWorklistReader.cs @@ -0,0 +1,20 @@ +using New.Application.Ports; +using New.Application.Worklist; + +namespace New.Infrastructure.Legacy; + +/// Seam A list read: GET /api/aanvragen, for the merged worklist. +public sealed class LegacyWorklistReader : ILegacyWorklistReader +{ + private readonly LegacyBackendClient _client; + + // Internal constructor: see LegacyCaseSource's remarks. Registered via an + // explicit factory in ServiceCollectionExtensions, not auto-construction. + internal LegacyWorklistReader(LegacyBackendClient client) => _client = client; + + public async Task> ListAsync(CancellationToken ct) + { + var rows = await _client.ListAanvragenAsync(ct); + return rows.Select(LegacyCaseDetailProjection.ToWorklistItem).ToList(); + } +} diff --git a/new/src/New.Infrastructure.Legacy/New.Infrastructure.Legacy.csproj b/new/src/New.Infrastructure.Legacy/New.Infrastructure.Legacy.csproj new file mode 100644 index 0000000..4414b5d --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/New.Infrastructure.Legacy.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + true + New.Infrastructure.Legacy + + + + + + + + + + + + + + diff --git a/new/src/New.Infrastructure.Legacy/Options/LegacyBackendOptions.cs b/new/src/New.Infrastructure.Legacy/Options/LegacyBackendOptions.cs new file mode 100644 index 0000000..ea74a26 --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/Options/LegacyBackendOptions.cs @@ -0,0 +1,8 @@ +namespace New.Infrastructure.Legacy.Options; + +public sealed class LegacyBackendOptions +{ + public const string SectionName = "Services:LegacyBackend"; + + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/new/src/New.Infrastructure.Legacy/ServiceCollectionExtensions.cs b/new/src/New.Infrastructure.Legacy/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..aef625b --- /dev/null +++ b/new/src/New.Infrastructure.Legacy/ServiceCollectionExtensions.cs @@ -0,0 +1,62 @@ +using System.Net.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using New.Application.Ports; +using New.Infrastructure.Legacy.Options; + +namespace New.Infrastructure.Legacy; + +/// +/// Composition-root entry point for this project - see +/// New.Infrastructure.Persistence.ServiceCollectionExtensions for why +/// Program.cs only ever calls extension methods like this one, never names +/// LegacyCaseSource/OwnedApplicationSource directly itself. +/// +/// Every service below that depends on an `internal` type (LegacyBackendClient, +/// LegacyDetailsWriteThroughTranslator - both internal because their APIs are +/// shaped by legacy DTOs, see Architecture.Tests rule 3) is registered via an +/// explicit factory delegate rather than `services.AddScoped<T>()`'s +/// automatic constructor discovery. That auto-discovery goes through +/// reflection in a different assembly and is not guaranteed to see +/// non-public constructors; a factory delegate compiled here, in the same +/// assembly, calls the constructor directly under ordinary C# accessibility +/// rules - no reflection involved, so there's nothing to be uncertain about. +/// +public static class ServiceCollectionExtensions +{ + private const string HttpClientName = "LegacyBackend"; + + public static IServiceCollection AddLegacyInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(LegacyBackendOptions.SectionName)); + + services.AddSingleton(); + services.AddTransient(); + + services.AddHttpClient(HttpClientName, (sp, http) => + { + var options = sp.GetRequiredService>().Value; + http.BaseAddress = new Uri(options.BaseUrl); + }).AddHttpMessageHandler(); + + services.AddScoped(sp => + new LegacyBackendClient(sp.GetRequiredService().CreateClient(HttpClientName))); + + services.AddScoped(sp => + new LegacyDetailsWriteThroughTranslator(sp.GetRequiredService>())); + + services.AddScoped(sp => + new LegacyCaseSource(sp.GetRequiredService())); + + services.AddScoped(sp => + new LegacyWorklistReader(sp.GetRequiredService())); + + services.AddScoped(sp => + new LegacyCaseGateway( + sp.GetRequiredService(), + sp.GetRequiredService())); + + return services; + } +} diff --git a/new/src/New.Infrastructure.Persistence/CaseDetailProjection.cs b/new/src/New.Infrastructure.Persistence/CaseDetailProjection.cs new file mode 100644 index 0000000..9933646 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/CaseDetailProjection.cs @@ -0,0 +1,67 @@ +using New.Application.Worklist; +using New.Infrastructure.Persistence.Entities; + +namespace New.Infrastructure.Persistence; + +/// Shared record -> read-model projection, used by both OwnedApplicationSource and OwnedWorklistReader. +internal static class CaseDetailProjection +{ + public static CaseDetail FromRecord(RegistrationApplicationRecord record, int? legacyAanvraagId) + { + AddressData? address = record.AddressStreet is not null + ? new AddressData(record.AddressStreet, record.AddressNumber!, record.AddressPostalCode!, record.AddressCity!) + : null; + + AssessmentData? assessment = record.AssessmentOutcome is not null + ? new AssessmentData( + record.AssessmentOutcome, + record.AssessmentMotivation ?? string.Empty, + record.AssessmentVerifiedItems, + record.AssessmentExceptionReason, + record.AssessmentRejectionCategory, + record.AssessmentDecidedOn ?? record.ReceivedOn) + : null; + + return new CaseDetail( + WorklistOrigin.Owned, + legacyAanvraagId, + record.Id, + record.Surname, + record.Initials, + record.Bsn, + address, + record.Email, + record.Phone, + record.PreferredChannel, + record.DiplomaCode, + record.DiplomaCountryOfIssue, + record.DiplomaIssuedOn, + record.ReceivedOn, + assessment, + record.CaseProcessStatus, + record.CaseFrameworkCaseId, + Migrated: legacyAanvraagId is not null); + } + + public static WorklistItem ToWorklistItem(RegistrationApplicationRecord record, int? legacyAanvraagId) + { + // Owned cases don't carry the legacy statCd vocabulary. As a + // pragmatic simplification for the merged worklist's `bucket` + // filter, we mirror it loosely: no assessment yet reads as "Open", + // any recorded assessment reads as "Beoordeeld" - the same two + // bucket labels legacy uses for the equivalent stages. + var bucket = record.AssessmentOutcome is null ? "Open" : "Beoordeeld"; + + return new WorklistItem( + WorklistOrigin.Owned, + legacyAanvraagId, + record.Id, + record.Surname, + record.Initials, + record.Bsn, + record.ReceivedOn, + bucket, + record.AssessmentOutcome, + record.CaseProcessStatus); + } +} diff --git a/new/src/New.Infrastructure.Persistence/Configurations/LegacyOwnershipRowConfiguration.cs b/new/src/New.Infrastructure.Persistence/Configurations/LegacyOwnershipRowConfiguration.cs new file mode 100644 index 0000000..614b868 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Configurations/LegacyOwnershipRowConfiguration.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using New.Infrastructure.Persistence.Entities; + +namespace New.Infrastructure.Persistence.Configurations; + +/// Maps exactly to the `legacy_ownership` schema given in the design notes. +public sealed class LegacyOwnershipRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("legacy_ownership"); + + builder.HasKey(x => x.LegacyAanvraagId); + builder.Property(x => x.LegacyAanvraagId) + .HasColumnName("legacy_aanvraag_id") + .ValueGeneratedNever(); + + builder.Property(x => x.RegistrationApplicationId) + .HasColumnName("registration_application_id") + .IsRequired(); + builder.HasIndex(x => x.RegistrationApplicationId).IsUnique(); + + // Schema specifies TIMESTAMP (without time zone), not TIMESTAMPTZ. + // We always deal in UTC instants (TimeProvider.GetUtcNow()), so we + // store the UTC instant as a naive timestamp rather than widening the + // column to timestamptz - the offset is always zero by construction. + builder.Property(x => x.TakenOverAt) + .HasColumnName("taken_over_at") + .HasColumnType("timestamp") + .HasConversion( + // Npgsql rejects a Kind=Utc DateTime against a "timestamp + // without time zone" column (it only accepts Kind=Unspecified + // there, to avoid silently implying a timezone the column + // doesn't have) - strip the Kind marker, the offset is always + // zero by construction so no information is lost. + toProvider => DateTime.SpecifyKind(toProvider.UtcDateTime, DateTimeKind.Unspecified), + fromProvider => new DateTimeOffset(DateTime.SpecifyKind(fromProvider, DateTimeKind.Utc))) + .IsRequired(); + + builder.Property(x => x.DomainWritesSince) + .HasColumnName("domain_writes_since") + .HasDefaultValue(0) + .IsRequired(); + } +} diff --git a/new/src/New.Infrastructure.Persistence/Configurations/RegistrationApplicationRecordConfiguration.cs b/new/src/New.Infrastructure.Persistence/Configurations/RegistrationApplicationRecordConfiguration.cs new file mode 100644 index 0000000..8a6c5f6 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Configurations/RegistrationApplicationRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using New.Infrastructure.Persistence.Entities; + +namespace New.Infrastructure.Persistence.Configurations; + +public sealed class RegistrationApplicationRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("registration_applications"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Bsn).HasMaxLength(9).IsRequired(); + builder.Property(x => x.Surname).IsRequired(); + builder.Property(x => x.Initials).IsRequired(); + builder.Property(x => x.PreferredChannel).IsRequired(); + builder.Property(x => x.DiplomaCode).IsRequired(); + builder.Property(x => x.DiplomaCountryOfIssue).IsRequired(); + + // Npgsql maps List to a native Postgres text[] column. + builder.Property(x => x.AssessmentVerifiedItems).HasColumnType("text[]"); + } +} diff --git a/new/src/New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs b/new/src/New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs new file mode 100644 index 0000000..2c5e0b4 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs @@ -0,0 +1,15 @@ +namespace New.Infrastructure.Persistence.Entities; + +/// +/// Persistence-only row type for the `legacy_ownership` table. Deliberately +/// NOT a domain concept (the spec calls it "a simple table, not an +/// aggregate") - plain data, no behavior, no invariants of its own beyond +/// what SQL constraints already express. +/// +public sealed class LegacyOwnershipRow +{ + public int LegacyAanvraagId { get; set; } + public Guid RegistrationApplicationId { get; set; } + public DateTimeOffset TakenOverAt { get; set; } + public int DomainWritesSince { get; set; } +} diff --git a/new/src/New.Infrastructure.Persistence/Entities/RegistrationApplicationRecord.cs b/new/src/New.Infrastructure.Persistence/Entities/RegistrationApplicationRecord.cs new file mode 100644 index 0000000..2289ab7 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Entities/RegistrationApplicationRecord.cs @@ -0,0 +1,47 @@ +namespace New.Infrastructure.Persistence.Entities; + +/// +/// Persistence model for the +/// aggregate. Deliberately a separate, plain, mutable class rather than +/// mapping the rich aggregate (private setters, no parameterless +/// constructor, records-as-owned-types) straight into EF Core - that would +/// either force EF-shaped constructor parameters onto the domain type or +/// fight EF's constructor-binding conventions for no real benefit. Instead, +/// RegistrationApplicationRepository translates explicitly in both +/// directions, keeping New.Domain entirely free of any EF Core awareness. +/// +public sealed class RegistrationApplicationRecord +{ + public Guid Id { get; set; } + + public string Bsn { get; set; } = string.Empty; + + public string Surname { get; set; } = string.Empty; + public string Initials { get; set; } = string.Empty; + + public string? AddressStreet { get; set; } + public string? AddressNumber { get; set; } + public string? AddressPostalCode { get; set; } + public string? AddressCity { get; set; } + + public string? Email { get; set; } + public string? Phone { get; set; } + public string PreferredChannel { get; set; } = string.Empty; + + public string DiplomaCode { get; set; } = string.Empty; + public string DiplomaCountryOfIssue { get; set; } = string.Empty; + public DateOnly DiplomaIssuedOn { get; set; } + + public string? AssessmentOutcome { get; set; } + public string? AssessmentMotivation { get; set; } + public List AssessmentVerifiedItems { get; set; } = []; + public string? AssessmentExceptionReason { get; set; } + public string? AssessmentRejectionCategory { get; set; } + public DateOnly? AssessmentDecidedOn { get; set; } + + public Guid? CaseFrameworkCaseId { get; set; } + public string? CaseExternalReference { get; set; } + public string? CaseProcessStatus { get; set; } + + public DateOnly ReceivedOn { get; set; } +} diff --git a/new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.Designer.cs b/new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.Designer.cs new file mode 100644 index 0000000..0bd9b0c --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.Designer.cs @@ -0,0 +1,147 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using New.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace New.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NewDbContext))] + [Migration("20260730161623_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("New.Infrastructure.Persistence.Entities.LegacyOwnershipRow", b => + { + b.Property("LegacyAanvraagId") + .HasColumnType("integer") + .HasColumnName("legacy_aanvraag_id"); + + b.Property("DomainWritesSince") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("domain_writes_since"); + + b.Property("RegistrationApplicationId") + .HasColumnType("uuid") + .HasColumnName("registration_application_id"); + + b.Property("TakenOverAt") + .HasColumnType("timestamp") + .HasColumnName("taken_over_at"); + + b.HasKey("LegacyAanvraagId"); + + b.HasIndex("RegistrationApplicationId") + .IsUnique(); + + b.ToTable("legacy_ownership", (string)null); + }); + + modelBuilder.Entity("New.Infrastructure.Persistence.Entities.RegistrationApplicationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressCity") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("AddressPostalCode") + .HasColumnType("text"); + + b.Property("AddressStreet") + .HasColumnType("text"); + + b.Property("AssessmentDecidedOn") + .HasColumnType("date"); + + b.Property("AssessmentExceptionReason") + .HasColumnType("text"); + + b.Property("AssessmentMotivation") + .HasColumnType("text"); + + b.Property("AssessmentOutcome") + .HasColumnType("text"); + + b.Property("AssessmentRejectionCategory") + .HasColumnType("text"); + + b.PrimitiveCollection>("AssessmentVerifiedItems") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Bsn") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("CaseExternalReference") + .HasColumnType("text"); + + b.Property("CaseFrameworkCaseId") + .HasColumnType("uuid"); + + b.Property("CaseProcessStatus") + .HasColumnType("text"); + + b.Property("DiplomaCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiplomaCountryOfIssue") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiplomaIssuedOn") + .HasColumnType("date"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Initials") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("PreferredChannel") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceivedOn") + .HasColumnType("date"); + + b.Property("Surname") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("registration_applications", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.cs b/new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.cs new file mode 100644 index 0000000..944f86d --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Migrations/20260730161623_InitialCreate.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace New.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "legacy_ownership", + columns: table => new + { + legacy_aanvraag_id = table.Column(type: "integer", nullable: false), + registration_application_id = table.Column(type: "uuid", nullable: false), + taken_over_at = table.Column(type: "timestamp", nullable: false), + domain_writes_since = table.Column(type: "integer", nullable: false, defaultValue: 0) + }, + constraints: table => + { + table.PrimaryKey("PK_legacy_ownership", x => x.legacy_aanvraag_id); + }); + + migrationBuilder.CreateTable( + name: "registration_applications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Bsn = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + Surname = table.Column(type: "text", nullable: false), + Initials = table.Column(type: "text", nullable: false), + AddressStreet = table.Column(type: "text", nullable: true), + AddressNumber = table.Column(type: "text", nullable: true), + AddressPostalCode = table.Column(type: "text", nullable: true), + AddressCity = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + Phone = table.Column(type: "text", nullable: true), + PreferredChannel = table.Column(type: "text", nullable: false), + DiplomaCode = table.Column(type: "text", nullable: false), + DiplomaCountryOfIssue = table.Column(type: "text", nullable: false), + DiplomaIssuedOn = table.Column(type: "date", nullable: false), + AssessmentOutcome = table.Column(type: "text", nullable: true), + AssessmentMotivation = table.Column(type: "text", nullable: true), + AssessmentVerifiedItems = table.Column>(type: "text[]", nullable: false), + AssessmentExceptionReason = table.Column(type: "text", nullable: true), + AssessmentRejectionCategory = table.Column(type: "text", nullable: true), + AssessmentDecidedOn = table.Column(type: "date", nullable: true), + CaseFrameworkCaseId = table.Column(type: "uuid", nullable: true), + CaseExternalReference = table.Column(type: "text", nullable: true), + CaseProcessStatus = table.Column(type: "text", nullable: true), + ReceivedOn = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_registration_applications", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_legacy_ownership_registration_application_id", + table: "legacy_ownership", + column: "registration_application_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "legacy_ownership"); + + migrationBuilder.DropTable( + name: "registration_applications"); + } + } +} diff --git a/new/src/New.Infrastructure.Persistence/Migrations/NewDbContextModelSnapshot.cs b/new/src/New.Infrastructure.Persistence/Migrations/NewDbContextModelSnapshot.cs new file mode 100644 index 0000000..c7389fb --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Migrations/NewDbContextModelSnapshot.cs @@ -0,0 +1,144 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using New.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace New.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NewDbContext))] + partial class NewDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("New.Infrastructure.Persistence.Entities.LegacyOwnershipRow", b => + { + b.Property("LegacyAanvraagId") + .HasColumnType("integer") + .HasColumnName("legacy_aanvraag_id"); + + b.Property("DomainWritesSince") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("domain_writes_since"); + + b.Property("RegistrationApplicationId") + .HasColumnType("uuid") + .HasColumnName("registration_application_id"); + + b.Property("TakenOverAt") + .HasColumnType("timestamp") + .HasColumnName("taken_over_at"); + + b.HasKey("LegacyAanvraagId"); + + b.HasIndex("RegistrationApplicationId") + .IsUnique(); + + b.ToTable("legacy_ownership", (string)null); + }); + + modelBuilder.Entity("New.Infrastructure.Persistence.Entities.RegistrationApplicationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressCity") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("AddressPostalCode") + .HasColumnType("text"); + + b.Property("AddressStreet") + .HasColumnType("text"); + + b.Property("AssessmentDecidedOn") + .HasColumnType("date"); + + b.Property("AssessmentExceptionReason") + .HasColumnType("text"); + + b.Property("AssessmentMotivation") + .HasColumnType("text"); + + b.Property("AssessmentOutcome") + .HasColumnType("text"); + + b.Property("AssessmentRejectionCategory") + .HasColumnType("text"); + + b.PrimitiveCollection>("AssessmentVerifiedItems") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Bsn") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("CaseExternalReference") + .HasColumnType("text"); + + b.Property("CaseFrameworkCaseId") + .HasColumnType("uuid"); + + b.Property("CaseProcessStatus") + .HasColumnType("text"); + + b.Property("DiplomaCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiplomaCountryOfIssue") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiplomaIssuedOn") + .HasColumnType("date"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Initials") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("PreferredChannel") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceivedOn") + .HasColumnType("date"); + + b.Property("Surname") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("registration_applications", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/new/src/New.Infrastructure.Persistence/New.Infrastructure.Persistence.csproj b/new/src/New.Infrastructure.Persistence/New.Infrastructure.Persistence.csproj new file mode 100644 index 0000000..0da5e9c --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/New.Infrastructure.Persistence.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + true + New.Infrastructure.Persistence + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/new/src/New.Infrastructure.Persistence/NewDbContext.cs b/new/src/New.Infrastructure.Persistence/NewDbContext.cs new file mode 100644 index 0000000..73db6f7 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/NewDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using New.Infrastructure.Persistence.Entities; + +namespace New.Infrastructure.Persistence; + +public sealed class NewDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet RegistrationApplications => Set(); + + public DbSet LegacyOwnership => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(NewDbContext).Assembly); + } +} diff --git a/new/src/New.Infrastructure.Persistence/NewDbContextFactory.cs b/new/src/New.Infrastructure.Persistence/NewDbContextFactory.cs new file mode 100644 index 0000000..923b545 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/NewDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace New.Infrastructure.Persistence; + +/// +/// Design-time-only factory so `dotnet ef migrations add/update` can +/// construct a NewDbContext without a running host (this project has no DI +/// container of its own - New.Api's ServiceCollectionExtensions.AddPersistenceInfrastructure +/// is what wires the real DbContextOptions at runtime, reading +/// ConnectionStrings__New from the environment). Never used outside `dotnet ef`. +/// +public sealed class NewDbContextFactory : IDesignTimeDbContextFactory +{ + public NewDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=newdb;Username=postgres;Password=postgres"); + return new NewDbContext(optionsBuilder.Options); + } +} diff --git a/new/src/New.Infrastructure.Persistence/OwnedApplicationSource.cs b/new/src/New.Infrastructure.Persistence/OwnedApplicationSource.cs new file mode 100644 index 0000000..7183dd2 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/OwnedApplicationSource.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using New.Application.Worklist; + +namespace New.Infrastructure.Persistence; + +/// +/// Fetches a single owned case by its RegistrationApplicationId, +/// projected straight from the stored record (no need to reconstruct and +/// re-validate the domain aggregate just to read it back). +/// +/// Deliberately a concrete class with no interface of its own - it exists +/// only to be injected into the source resolver (New.Api) alongside +/// LegacyCaseSource, and Architecture.Tests rule 7 asserts the resolver is +/// the only type that references both of them. +/// +public sealed class OwnedApplicationSource(NewDbContext db) +{ + public async Task GetAsync(Guid registrationApplicationId, CancellationToken ct) + { + var record = await db.RegistrationApplications.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == registrationApplicationId, ct); + + if (record is null) + { + return null; + } + + var ownership = await db.LegacyOwnership.AsNoTracking() + .FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct); + + return CaseDetailProjection.FromRecord(record, ownership?.LegacyAanvraagId); + } +} diff --git a/new/src/New.Infrastructure.Persistence/Repositories/OwnedWorklistReader.cs b/new/src/New.Infrastructure.Persistence/Repositories/OwnedWorklistReader.cs new file mode 100644 index 0000000..9148163 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Repositories/OwnedWorklistReader.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using New.Application.Ports; +using New.Application.Worklist; + +namespace New.Infrastructure.Persistence.Repositories; + +public sealed class OwnedWorklistReader(NewDbContext db) : IOwnedWorklistReader +{ + public async Task> ListAsync(CancellationToken ct) + { + var records = await db.RegistrationApplications.AsNoTracking().ToListAsync(ct); + var ownershipByOwnedId = await db.LegacyOwnership.AsNoTracking() + .ToDictionaryAsync(x => x.RegistrationApplicationId, x => x.LegacyAanvraagId, ct); + + return records + .Select(r => CaseDetailProjection.ToWorklistItem( + r, + ownershipByOwnedId.TryGetValue(r.Id, out var legacyId) ? legacyId : null)) + .ToList(); + } +} diff --git a/new/src/New.Infrastructure.Persistence/Repositories/OwnershipRegistry.cs b/new/src/New.Infrastructure.Persistence/Repositories/OwnershipRegistry.cs new file mode 100644 index 0000000..f7391f9 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Repositories/OwnershipRegistry.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using New.Application.Ports; +using New.Infrastructure.Persistence.Entities; + +namespace New.Infrastructure.Persistence.Repositories; + +public sealed class OwnershipRegistry(NewDbContext db) : IOwnershipRegistry +{ + public async Task LookupOwnedIdAsync(int legacyAanvraagId, CancellationToken ct) + { + var row = await db.LegacyOwnership.AsNoTracking() + .FirstOrDefaultAsync(x => x.LegacyAanvraagId == legacyAanvraagId, ct); + return row?.RegistrationApplicationId; + } + + public async Task GetAsync(Guid registrationApplicationId, CancellationToken ct) + { + var row = await db.LegacyOwnership.AsNoTracking() + .FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct); + + return row is null + ? null + : new OwnershipRecord(row.LegacyAanvraagId, row.RegistrationApplicationId, row.TakenOverAt, row.DomainWritesSince); + } + + public Task RecordAsync(int legacyAanvraagId, Guid registrationApplicationId, DateTimeOffset takenOverAt, CancellationToken ct) + { + db.LegacyOwnership.Add(new LegacyOwnershipRow + { + LegacyAanvraagId = legacyAanvraagId, + RegistrationApplicationId = registrationApplicationId, + TakenOverAt = takenOverAt, + DomainWritesSince = 0, + }); + return Task.CompletedTask; + } + + public async Task IncrementDomainWritesAsync(Guid registrationApplicationId, CancellationToken ct) + { + var row = await db.LegacyOwnership.FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct); + if (row is not null) + { + row.DomainWritesSince += 1; + } + } + + public async Task RemoveAsync(Guid registrationApplicationId, CancellationToken ct) + { + var row = await db.LegacyOwnership.FirstOrDefaultAsync(x => x.RegistrationApplicationId == registrationApplicationId, ct); + if (row is not null) + { + db.LegacyOwnership.Remove(row); + } + } +} diff --git a/new/src/New.Infrastructure.Persistence/Repositories/RegistrationApplicationRepository.cs b/new/src/New.Infrastructure.Persistence/Repositories/RegistrationApplicationRepository.cs new file mode 100644 index 0000000..2aedc93 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Repositories/RegistrationApplicationRepository.cs @@ -0,0 +1,148 @@ +using Microsoft.EntityFrameworkCore; +using New.Application.Ports; +using New.Domain; +using New.Domain.ValueObjects; +using New.Infrastructure.Persistence.Entities; + +namespace New.Infrastructure.Persistence.Repositories; + +/// +/// EF-backed adapter for . +/// +/// Because is a rich domain object (not +/// itself an EF entity - see the remarks on ), +/// this repository keeps track of every aggregate it has handed out or +/// staged for insertion, alongside its backing record. +/// re-copies each tracked aggregate's current state into its record right +/// before calls SaveChangesAsync, so mutations made +/// through the aggregate's own methods (RecordAssessment, UpdateApplicantDetails, +/// AttachCaseReference, ...) are what actually gets persisted - not a second, +/// independently-mutated copy. +/// +public sealed class RegistrationApplicationRepository(NewDbContext db) : IRegistrationApplicationRepository +{ + private readonly List<(RegistrationApplication Domain, RegistrationApplicationRecord Record)> _tracked = []; + + public async Task GetAsync(Guid registrationApplicationId, CancellationToken ct) + { + var record = await db.RegistrationApplications + .FirstOrDefaultAsync(x => x.Id == registrationApplicationId, ct); + + if (record is null) + { + return null; + } + + var domain = ToDomain(record); + _tracked.Add((domain, record)); + return domain; + } + + public Task AddAsync(RegistrationApplication application, CancellationToken ct) + { + var record = new RegistrationApplicationRecord { Id = application.RegistrationApplicationId }; + Populate(record, application); + db.RegistrationApplications.Add(record); + _tracked.Add((application, record)); + return Task.CompletedTask; + } + + public async Task RemoveAsync(RegistrationApplication application, CancellationToken ct) + { + var record = await db.RegistrationApplications + .FirstOrDefaultAsync(x => x.Id == application.RegistrationApplicationId, ct); + + if (record is not null) + { + db.RegistrationApplications.Remove(record); + } + + _tracked.RemoveAll(t => t.Domain.RegistrationApplicationId == application.RegistrationApplicationId); + } + + /// Called by immediately before SaveChangesAsync. + internal void FlushTrackedChangesToRecords() + { + foreach (var (domain, record) in _tracked) + { + Populate(record, domain); + } + } + + private static void Populate(RegistrationApplicationRecord record, RegistrationApplication domain) + { + record.Bsn = domain.Bsn.Value; + record.Surname = domain.Applicant.Surname; + record.Initials = domain.Applicant.Initials; + + record.AddressStreet = domain.CorrespondenceAddress?.Street; + record.AddressNumber = domain.CorrespondenceAddress?.Number; + record.AddressPostalCode = domain.CorrespondenceAddress?.PostalCode; + record.AddressCity = domain.CorrespondenceAddress?.City; + + record.Email = domain.ContactDetails.Email; + record.Phone = domain.ContactDetails.Phone; + record.PreferredChannel = domain.ContactDetails.PreferredChannel.ToString(); + + record.DiplomaCode = domain.DiplomaEvidence.Code; + record.DiplomaCountryOfIssue = domain.DiplomaEvidence.CountryOfIssue; + record.DiplomaIssuedOn = domain.DiplomaEvidence.IssuedOn; + + record.AssessmentOutcome = domain.Assessment?.Outcome.ToString(); + record.AssessmentMotivation = domain.Assessment?.Motivation; + record.AssessmentVerifiedItems = domain.Assessment?.VerifiedItems.ToList() ?? []; + record.AssessmentExceptionReason = domain.Assessment?.ExceptionReason; + record.AssessmentRejectionCategory = domain.Assessment?.RejectionCategory; + record.AssessmentDecidedOn = domain.Assessment?.DecidedOn; + + record.CaseFrameworkCaseId = domain.Case?.FrameworkCaseId; + record.CaseExternalReference = domain.Case?.ExternalReference; + record.CaseProcessStatus = domain.Case?.ProcessStatus; + + record.ReceivedOn = domain.ReceivedOn; + } + + internal static RegistrationApplication ToDomain(RegistrationApplicationRecord record) + { + var bsn = new Bsn(record.Bsn); + var applicant = new PersonName(record.Surname, record.Initials); + var address = HasAllFourAddressParts(record) + ? new Address(record.AddressStreet!, record.AddressNumber!, record.AddressPostalCode!, record.AddressCity!) + : null; + var channel = Enum.Parse(record.PreferredChannel); + var contactDetails = new ContactDetails(record.Email, record.Phone, channel); + var diploma = new DiplomaEvidence(record.DiplomaCode, record.DiplomaCountryOfIssue, record.DiplomaIssuedOn); + + var caseReference = record.CaseFrameworkCaseId is { } caseId + ? new CaseReference(caseId, record.CaseExternalReference ?? string.Empty, record.CaseProcessStatus) + : null; + + RegistrationApplication application; + if (record.AssessmentOutcome is { } outcomeText) + { + var assessment = Assessment.Create( + Enum.Parse(outcomeText), + record.AssessmentMotivation ?? string.Empty, + record.AssessmentVerifiedItems, + record.AssessmentExceptionReason, + record.AssessmentRejectionCategory, + record.AssessmentDecidedOn ?? record.ReceivedOn); + + application = RegistrationApplication.CreateWithAssessment( + record.Id, bsn, applicant, address, contactDetails, diploma, record.ReceivedOn, assessment, caseReference); + } + else + { + application = RegistrationApplication.Create( + record.Id, bsn, applicant, address, contactDetails, diploma, record.ReceivedOn, caseReference); + } + + return application; + } + + private static bool HasAllFourAddressParts(RegistrationApplicationRecord record) => + record.AddressStreet is not null && + record.AddressNumber is not null && + record.AddressPostalCode is not null && + record.AddressCity is not null; +} diff --git a/new/src/New.Infrastructure.Persistence/Repositories/UnitOfWork.cs b/new/src/New.Infrastructure.Persistence/Repositories/UnitOfWork.cs new file mode 100644 index 0000000..ec4b91c --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/Repositories/UnitOfWork.cs @@ -0,0 +1,14 @@ +using New.Application.Ports; + +namespace New.Infrastructure.Persistence.Repositories; + +public sealed class UnitOfWork(NewDbContext db, RegistrationApplicationRepository repository) : IUnitOfWork +{ + public Task SaveChangesAsync(CancellationToken ct) + { + // Re-copy every tracked aggregate's current state into its record + // before committing - see RegistrationApplicationRepository's remarks. + repository.FlushTrackedChangesToRecords(); + return db.SaveChangesAsync(ct); + } +} diff --git a/new/src/New.Infrastructure.Persistence/ServiceCollectionExtensions.cs b/new/src/New.Infrastructure.Persistence/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f080ed1 --- /dev/null +++ b/new/src/New.Infrastructure.Persistence/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using New.Application.Ports; +using New.Infrastructure.Persistence.Repositories; + +namespace New.Infrastructure.Persistence; + +/// +/// Composition-root entry point for this project. Program.cs calls only this +/// extension method and never names OwnedApplicationSource/LegacyCaseSource +/// (from New.Infrastructure.Legacy) directly - that keeps Program.cs itself +/// from being a second type that references both "source" types, which would +/// undermine the resolver-exclusivity asserted by Architecture.Tests rule 7. +/// +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddPersistenceInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("New"))); + + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/new/tests/Architecture.Tests/Architecture.Tests.csproj b/new/tests/Architecture.Tests/Architecture.Tests.csproj new file mode 100644 index 0000000..87afa37 --- /dev/null +++ b/new/tests/Architecture.Tests/Architecture.Tests.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + enable + enable + true + false + Architecture.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/new/tests/Architecture.Tests/ArchitectureTests.cs b/new/tests/Architecture.Tests/ArchitectureTests.cs new file mode 100644 index 0000000..e765cd5 --- /dev/null +++ b/new/tests/Architecture.Tests/ArchitectureTests.cs @@ -0,0 +1,203 @@ +using System.Reflection; +using NetArchTest.Rules; +using New.Application.Ownership; +using New.Infrastructure.CaseFramework; +using New.Infrastructure.Legacy; +using New.Infrastructure.Persistence; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Encodes §10's architecture rules as build-failing assertions. A demo that +/// passes the smoke script but fails these has demonstrated nothing - the +/// seam boundaries are the point, not an implementation detail. +/// +public class ArchitectureTests +{ + private static readonly Assembly DomainAssembly = typeof(New.Domain.RegistrationApplication).Assembly; + private static readonly Assembly ApplicationAssembly = typeof(New.Application.Ports.IApplicationSource).Assembly; + private static readonly Assembly PersistenceAssembly = typeof(NewDbContext).Assembly; + private static readonly Assembly LegacyAssembly = typeof(LegacyCaseSource).Assembly; + private static readonly Assembly CaseFrameworkAssembly = typeof(CaseFrameworkGateway).Assembly; + private static readonly Assembly ApiAssembly = typeof(New.Api.Endpoints.WorklistEndpoints).Assembly; + + private static readonly Assembly[] AllNewAssemblies = + [ + DomainAssembly, ApplicationAssembly, PersistenceAssembly, LegacyAssembly, CaseFrameworkAssembly, ApiAssembly, + ]; + + [Fact] + public void Rule1_Domain_And_Application_Have_No_Dependency_On_CaseFramework() + { + var result = Types.InAssembly(DomainAssembly).Should().NotHaveDependencyOn("New.Infrastructure.CaseFramework").GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + + result = Types.InAssembly(ApplicationAssembly).Should().NotHaveDependencyOn("New.Infrastructure.CaseFramework").GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + } + + [Fact] + public void Rule2_Domain_And_Application_Have_No_Dependency_On_Legacy() + { + var result = Types.InAssembly(DomainAssembly).Should().NotHaveDependencyOn("New.Infrastructure.Legacy").GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + + result = Types.InAssembly(ApplicationAssembly).Should().NotHaveDependencyOn("New.Infrastructure.Legacy").GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + } + + [Fact] + public void Rule3_Legacy_Dtos_Are_Internal() + { + var result = Types.InAssembly(LegacyAssembly) + .That().ResideInNamespace("New.Infrastructure.Legacy.Dtos") + .Should().NotBePublic() + .GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + } + + [Fact] + public void Rule4_CaseFramework_Dtos_Are_Internal() + { + var result = Types.InAssembly(CaseFrameworkAssembly) + .That().ResideInNamespace("New.Infrastructure.CaseFramework.Dtos") + .Should().NotBePublic() + .GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + } + + [Fact] + public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config() + { + // Config-file concern, not code - see §10. Verified by inspection: the + // only connection string anywhere under New.* is ConnectionStrings:New + // (New.Infrastructure.Persistence.ServiceCollectionExtensions), and + // docker-compose.yml only ever injects ConnectionStrings__New into + // new-backend. Nothing to assert against compiled IL here. + Assert.True(true); + } + + [Fact] + public void Rule6_No_Public_Member_Named_Status_In_Domain() + { + var offendingMembers = DomainAssembly.GetTypes() + .Where(t => t.IsPublic || t.IsNestedPublic) + .SelectMany(t => t.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + .Where(m => (m is PropertyInfo || m is FieldInfo) && m.Name == "Status") + .ToList(); + + Assert.True(offendingMembers.Count == 0, + $"Found public member(s) named exactly 'Status' in New.Domain: {string.Join(", ", offendingMembers.Select(m => $"{m.DeclaringType!.Name}.{m.Name}"))}. " + + "Use ProcessStatus (case-framework-sourced) or AssessmentOutcome (domain decision) instead."); + } + + [Fact] + public void Rule7_ApplicationSourceResolver_Is_Only_Type_Referencing_Both_Sources() + { + // ApplicationSourceResolver is `internal` (New.Api) with no + // InternalsVisibleTo grant, so it can't be named via `typeof` here - + // looked up by name instead, exactly as NetArchTest itself inspects + // compiled IL rather than relying on compile-time visibility. + var resolverType = ApiAssembly.GetType("New.Api.Resolution.ApplicationSourceResolver"); + Assert.NotNull(resolverType); + + var ownedType = typeof(OwnedApplicationSource); + var legacyType = typeof(LegacyCaseSource); + + var typesReferencingBoth = AllNewAssemblies + .SelectMany(GetLoadableTypes) + .Where(t => ReferencesType(t, ownedType) && ReferencesType(t, legacyType)) + .ToList(); + + Assert.True( + typesReferencingBoth.Count == 1 && typesReferencingBoth[0] == resolverType, + $"Expected only {resolverType!.Name} to reference both {nameof(OwnedApplicationSource)} and {nameof(LegacyCaseSource)}, " + + $"but found: {string.Join(", ", typesReferencingBoth.Select(t => t.FullName))}"); + } + + [Fact] + public void Rule8_TakeOwnershipHandler_References_Only_Ports() + { + var result = Types.InAssembly(ApplicationAssembly) + .That().HaveName(nameof(TakeOwnershipHandler)) + .Should().NotHaveDependencyOnAny( + "New.Infrastructure.Persistence", "New.Infrastructure.Legacy", "New.Infrastructure.CaseFramework") + .GetResult(); + Assert.True(result.IsSuccessful, Describe(result)); + } + + [Fact] + public void Rule9_No_New_Project_References_SqlServer() + { + var offending = AllNewAssemblies + .Where(a => a.GetReferencedAssemblies().Any(r => r.Name == "Microsoft.EntityFrameworkCore.SqlServer")) + .ToList(); + + Assert.True(offending.Count == 0, + $"These New.* assemblies reference Microsoft.EntityFrameworkCore.SqlServer: {string.Join(", ", offending.Select(a => a.GetName().Name))}"); + } + + [Fact] + public void Rule10_Legacy_SqlServer_Only_Is_The_Legacy_Agents_Concern() + { + // Owned by legacy/ (a separate solution) - not referenceable from + // this test project. Verified by inspection there instead. + Assert.True(true); + } + + [Fact] + public void Rule11_Api_Never_Both_Constructs_Legacy_Dto_And_Touches_DbContext() + { + // Structurally all-but-guaranteed already: legacy DTOs are internal to + // New.Infrastructure.Legacy with no InternalsVisibleTo grant (rule 3), + // so New.Api cannot even name them, let alone construct one. This is + // therefore a best-effort namespace-level check, not full proof - see + // ADR-002 for why rule 11's stronger claim ("the write-through + // translator contains no branching on request values") is a review + // rule, not a machine-enforced one. + var apiTypesTouchingDbContext = Types.InAssembly(ApiAssembly) + .That().HaveDependencyOn("New.Infrastructure.Persistence") + .GetTypes(); + + var apiTypesTouchingLegacyDtos = Types.InAssembly(ApiAssembly) + .That().HaveDependencyOn("New.Infrastructure.Legacy.Dtos") + .GetTypes(); + + var overlap = apiTypesTouchingDbContext.Intersect(apiTypesTouchingLegacyDtos).ToList(); + + Assert.True(overlap.Count == 0, + $"These New.Api types both touch persistence and legacy DTOs: {string.Join(", ", overlap.Select(t => t.FullName))}"); + } + + private static bool ReferencesType(Type t, Type target) + { + if (t == target) + { + return false; + } + + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + var ctorParamTypes = t.GetConstructors(all).SelectMany(c => c.GetParameters()).Select(p => p.ParameterType); + var fieldTypes = t.GetFields(all).Select(f => f.FieldType); + var propTypes = t.GetProperties(all).Select(p => p.PropertyType); + + return ctorParamTypes.Concat(fieldTypes).Concat(propTypes).Any(x => x == target); + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } + + private static string Describe(TestResult result) => + result.IsSuccessful ? string.Empty : $"Failing types: {string.Join(", ", result.FailingTypes?.Select(t => t.FullName) ?? [])}"; +} diff --git a/proxy/nginx.conf b/proxy/nginx.conf new file mode 100644 index 0000000..ae89312 --- /dev/null +++ b/proxy/nginx.conf @@ -0,0 +1,34 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + + server { + listen 80; + + location /api/ { + proxy_pass http://new-backend:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Demo-User $http_x_demo_user; + } + + # Legacy.Web's own routes are "/legacy" and "/legacy/aanvraag/{id}/...", + # so the prefix is forwarded as-is (no trailing slash on proxy_pass, + # and no trailing slash on the location so a bare "/legacy" matches too). + location /legacy { + proxy_pass http://legacy-frontend:8080; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://new-frontend:80/; + proxy_set_header Host $host; + } + } +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..c22ec1d --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Smoke-tests the strangler-fig-demo stack against the acceptance criteria in +# the design notes (§13). Run against a FRESH `docker compose up` - it +# depends on the seed data being untouched (12 legacy rows 1001-1012, 5 owned +# REG-2026-0001..0005 at fixed ids). Exits non-zero on the first failure. +set -euo pipefail + +BASE="${SMOKE_BASE_URL:-http://localhost:8080}" +FAILURES=0 + +pass() { echo " OK $1"; } +fail() { echo " FAIL $1"; FAILURES=$((FAILURES + 1)); } + +check_status() { + local desc="$1" expected="$2" actual="$3" + if [ "$actual" = "$expected" ]; then pass "$desc ($actual)"; else fail "$desc (expected $expected, got $actual)"; fi +} + +json_field() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d$1)"; } + +echo "== Infrastructure ==" + +STATUS=$(docker compose ps --format json 2>/dev/null | python3 -c " +import sys, json +ok = True +for line in sys.stdin: + line = line.strip() + if not line: + continue + d = json.loads(line) + svc = d.get('Service') + state = d.get('State') + health = d.get('Health', '') + if state != 'running': + print(f'{svc}: state={state}'); ok = False + if health and health != 'healthy': + print(f'{svc}: health={health}'); ok = False +print('ALL_OK' if ok else 'SOME_FAILED') +") +if echo "$STATUS" | grep -q ALL_OK; then pass "all 9 containers running/healthy"; else fail "container status: $STATUS"; fi + +if curl -sS --max-time 2 http://localhost:8081 >/dev/null 2>&1; then + fail "legacy-backend must NOT be reachable from the host (port 8081 responded)" +else + pass "legacy-backend not reachable from host" +fi +if curl -sS --max-time 2 http://localhost:8082 >/dev/null 2>&1; then + fail "case-framework must NOT be reachable from the host (port 8082 responded)" +else + pass "case-framework not reachable from host" +fi + +echo "== Seam A: unified read ==" + +WORKLIST=$(curl -sS "$BASE/api/worklist") +TOTAL=$(echo "$WORKLIST" | json_field "['totalCount']") +if [ "$TOTAL" = "17" ]; then pass "GET /api/worklist returns 17 items"; else fail "expected 17 items, got $TOTAL"; fi + +DETAIL_1001=$(curl -sS "$BASE/api/worklist/legacy/1001") +SEAM_AANVRAGER=$(echo "$DETAIL_1001" | json_field "['seams']['aanvrager']") +check_status "A-1001 seam inspector names legacy-backend" "legacy-backend" "$SEAM_AANVRAGER" + +echo "== Write path 1: redirect (seam C) ==" + +MODE=$(echo "$DETAIL_1001" | json_field "['actions']['recordAssessment']['mode']") +check_status "A-1001 recordAssessment action is a redirect" "redirect" "$MODE" + +echo "== Write path 2: write-through (seam B) ==" + +HTTP=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT "$BASE/api/worklist/legacy/1001/details" \ + -H "Content-Type: application/json" \ + -d '{"surname":"de Vries","initials":"A.","address":{"street":"Kerkweg","number":"12","postalCode":"3512JK","city":"Utrecht"},"email":"anna.devries@example.nl","phone":"+31612345678","preferredChannel":"Post"}') +check_status "valid write-through details edit on A-1001" "204" "$HTTP" + +ERRORS=$(curl -sS -X PUT "$BASE/api/worklist/legacy/1001/details" \ + -H "Content-Type: application/json" \ + -d '{"surname":"","initials":"A.","address":{"street":"Kerkweg","number":"","postalCode":"12AB","city":"Utrecht"},"email":null,"phone":null,"preferredChannel":"Post"}') +ERROR_COUNT=$(echo "$ERRORS" | json_field "['errors'].__len__()" 2>/dev/null || echo "$ERRORS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['errors']))") +check_status "3-field-invalid write-through returns 3 mapped errors" "3" "$ERROR_COUNT" + +echo "== Write path 3: take ownership ==" + +TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership") +check_status "take ownership of A-1002" "201" "$TAKE" +REG_1002=$(python3 -c "import json; print(json.load(open('/tmp/take_1002.json'))['registrationApplicationId'])") + +DETAIL_1002=$(curl -sS "$BASE/api/worklist/owned/$REG_1002") +SEAM_AFTER=$(echo "$DETAIL_1002" | json_field "['seams']['aanvrager']") +check_status "adopted case's seam inspector now reads owned" "owned" "$SEAM_AFTER" + +BEFORE=$(curl -sS "$BASE/api/diagnostics/legacy-call-count" | json_field "['count']") +curl -sS -o /dev/null -X PUT "$BASE/api/worklist/owned/$REG_1002/details" \ + -H "Content-Type: application/json" \ + -d '{"surname":"Jansen","initials":"P.","address":null,"email":null,"phone":null,"preferredChannel":"Post"}' +AFTER=$(curl -sS "$BASE/api/diagnostics/legacy-call-count" | json_field "['count']") +check_status "editing the adopted case makes no legacy calls" "$BEFORE" "$AFTER" + +DIRECT=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT "$BASE/api/worklist/legacy/1002/details" \ + -H "Content-Type: application/json" -d '{"surname":"x","initials":"x","address":null,"email":null,"phone":null,"preferredChannel":"Post"}') +check_status "direct legacy write to an adopted case is blocked" "409" "$DIRECT" + +for pair in "1003:ContactDetails.EmailRequiredForEmailChannel" "1005:Bsn.ElevenProof" "1006:Assessment.MotivationTooShort" "1007:Address.AllPartsRequired"; do + ID="${pair%%:*}"; EXPECTED_INVARIANT="${pair##*:}" + RESULT=$(curl -sS -o /tmp/adopt_fail.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/$ID/take-ownership") + check_status "adoption of A-$ID fails" "422" "$RESULT" + ACTUAL_INVARIANT=$(python3 -c "import json; print(json.load(open('/tmp/adopt_fail.json')).get('invariant'))") + check_status "A-$ID fails on $EXPECTED_INVARIANT" "$EXPECTED_INVARIANT" "$ACTUAL_INVARIANT" +done + +RELEASE_1002=$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$BASE/api/worklist/owned/$REG_1002/ownership") +check_status "releasing an edited owned case is blocked" "409" "$RELEASE_1002" + +echo "== Owned assessment + conformist boundary (seam D) ==" + +INVALID_ASSESSMENT=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + "$BASE/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment" \ + -H "Content-Type: application/json" \ + -d '{"verifiedItems":[],"exceptionReason":null,"outcome":"Approved","rejectionCategory":null,"motivation":"too short"}') +check_status "direct invalid assessment payload is rejected" "422" "$INVALID_ASSESSMENT" + +ASSESSMENT=$(curl -sS -X POST "$BASE/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment" \ + -H "Content-Type: application/json" \ + -d '{"verifiedItems":["document","land","datum"],"exceptionReason":null,"outcome":"Approved","rejectionCategory":null,"motivation":"Alle bewijsstukken zijn gecontroleerd en akkoord bevonden."}') +CLOSURE_PENDING=$(echo "$ASSESSMENT" | json_field "['closurePending']") +check_status "REG-2026-0002 assessment reports closure pending (open task)" "True" "$CLOSURE_PENDING" + +OUTCOME_AFTER=$(curl -sS "$BASE/api/worklist/owned/00000000-0000-0000-0000-000000000002" | json_field "['assessment']['outcome']") +check_status "REG-2026-0002 outcome recorded, not rolled back" "Approved" "$OUTCOME_AFTER" + +echo +echo "== Architecture tests ==" +if (cd "$(dirname "$0")/../new" && dotnet test tests/Architecture.Tests/Architecture.Tests.csproj -c Release --nologo 2>&1 | tail -5 | grep -q "Failed: 0"); then + pass "all Architecture.Tests pass" +else + fail "Architecture.Tests reported failures" +fi + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "All checks passed." + exit 0 +else + echo "$FAILURES check(s) failed." + exit 1 +fi