feat: implement strangler-fig-demo Session 1 (backend + smoke script)
Builds the four-seam, three-write-path reference demo backend: case-framework (seam D stand-in), legacy-backend/frontend (SQL Server, seams A/B/C targets), and new-backend (Domain/Application/Infrastructure.*/Api implementing the source resolver, take/release-ownership, write-through translator, and owned assessment flow), wired together via docker-compose with a plain placeholder frontend standing in for the Angular portal until Session 2. All 11 Architecture.Tests pass and scripts/smoke.sh passes end-to-end against a fresh `docker compose up`, covering acceptance criteria 1-3 and 7-22. Fixes two real domain bugs found only once the stack ran for real: the BSN eleven-proof checksum trivially passes all-zero digits, and the adoption mapper silently treated a partial legacy address as absent instead of failing loudly. Also fixes several environment-specific integration issues (rootless Podman/SELinux bind-mount permissions, a buildah NuGet layer-caching bug, SqlClient's invariant-globalization incompatibility, and an nginx path-prefix mismatch for the legacy frontend). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
{"sessionId":"242e77f6-a2f0-4051-97fa-f360cc57a6ce","pid":386013,"procStart":"7345046","acquiredAt":1785426330459}
|
||||
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
node_modules/
|
||||
dist/
|
||||
.vs/
|
||||
*.user
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY src/CaseFramework.Api/ src/CaseFramework.Api/
|
||||
# restore+publish combined in one RUN/layer - see legacy/src/Legacy.Api/Dockerfile for why.
|
||||
RUN dotnet restore src/CaseFramework.Api/CaseFramework.Api.csproj && \
|
||||
dotnet publish src/CaseFramework.Api/CaseFramework.Api.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "CaseFramework.Api.dll"]
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CaseFramework.Api;
|
||||
|
||||
public class CaseDbContext(DbContextOptions<CaseDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<CaseEntity> Cases => Set<CaseEntity>();
|
||||
public DbSet<CaseTaskEntity> Tasks => Set<CaseTaskEntity>();
|
||||
public DbSet<TimelineEntryEntity> TimelineEntries => Set<TimelineEntryEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<CaseEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.Id);
|
||||
// Npgsql maps List<string> to a native Postgres text[] column.
|
||||
entity.Property(c => c.Participants).HasColumnType("text[]");
|
||||
|
||||
entity.HasMany(c => c.Tasks)
|
||||
.WithOne()
|
||||
.HasForeignKey(t => t.CaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(c => c.TimelineEntries)
|
||||
.WithOne()
|
||||
.HasForeignKey(t => t.CaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CaseTaskEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(t => t.Id);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TimelineEntryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(t => t.Id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace CaseFramework.Api;
|
||||
|
||||
/// <summary>
|
||||
/// A case managed by the framework. This is the framework's own aggregate;
|
||||
/// callers reference it by <see cref="Id"/> and may attach their own
|
||||
/// <see cref="ExternalReference"/> for correlation.
|
||||
/// </summary>
|
||||
public class CaseEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string CaseTypeCode { get; set; } = string.Empty;
|
||||
public string ExternalReference { get; set; } = string.Empty;
|
||||
public string ProcessStatus { get; set; } = string.Empty;
|
||||
public List<string> Participants { get; set; } = new();
|
||||
|
||||
public List<CaseTaskEntity> Tasks { get; set; } = new();
|
||||
public List<TimelineEntryEntity> TimelineEntries { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A unit of work that must be completed before a case may be closed.</summary>
|
||||
public class CaseTaskEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CaseId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public bool IsOpen { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>An immutable audit entry appended to a case's history.</summary>
|
||||
public class TimelineEntryEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CaseId { get; set; }
|
||||
public DateTimeOffset At { get; set; }
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class ProcessStatus
|
||||
{
|
||||
public const string InBehandeling = "InBehandeling";
|
||||
public const string Afgesloten = "Afgesloten";
|
||||
}
|
||||
|
||||
public static class TimelineKind
|
||||
{
|
||||
public const string CaseCreated = "CaseCreated";
|
||||
public const string TaskOpened = "TaskOpened";
|
||||
public const string TaskCompleted = "TaskCompleted";
|
||||
public const string CaseClosed = "CaseClosed";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using CaseFramework.Api;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddDbContext<CaseDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("CaseFramework")));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Self-provision schema against an empty Postgres database on first boot.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<CaseDbContext>();
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
app.MapPost("/cases", async (CreateCaseRequest request, CaseDbContext db) =>
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var caseEntity = new CaseEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CaseTypeCode = request.CaseTypeCode,
|
||||
ExternalReference = request.ExternalReference,
|
||||
ProcessStatus = ProcessStatus.InBehandeling,
|
||||
Participants = request.Participants?.ToList() ?? new List<string>(),
|
||||
};
|
||||
caseEntity.TimelineEntries.Add(new TimelineEntryEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CaseId = caseEntity.Id,
|
||||
At = now,
|
||||
Kind = TimelineKind.CaseCreated,
|
||||
Description = $"Case created with type '{caseEntity.CaseTypeCode}'.",
|
||||
});
|
||||
|
||||
db.Cases.Add(caseEntity);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new CreateCaseResponse(caseEntity.Id, caseEntity.ProcessStatus));
|
||||
});
|
||||
|
||||
app.MapGet("/cases/{id:guid}", async (Guid id, CaseDbContext db) =>
|
||||
{
|
||||
var caseEntity = await db.Cases.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (caseEntity is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new CaseResponse(
|
||||
caseEntity.Id,
|
||||
caseEntity.CaseTypeCode,
|
||||
caseEntity.ExternalReference,
|
||||
caseEntity.ProcessStatus,
|
||||
caseEntity.Participants));
|
||||
});
|
||||
|
||||
app.MapGet("/cases/{id:guid}/timeline", async (Guid id, CaseDbContext db) =>
|
||||
{
|
||||
var caseExists = await db.Cases.AnyAsync(c => c.Id == id);
|
||||
if (!caseExists)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var entries = await db.TimelineEntries
|
||||
.AsNoTracking()
|
||||
.Where(t => t.CaseId == id)
|
||||
.OrderBy(t => t.At)
|
||||
.Select(t => new TimelineEntryResponse(t.At, t.Kind, t.Description))
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(new TimelineResponse(entries));
|
||||
});
|
||||
|
||||
app.MapPost("/cases/{id:guid}/tasks", async (Guid id, CreateTaskRequest request, CaseDbContext db) =>
|
||||
{
|
||||
var caseEntity = await db.Cases.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (caseEntity is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var task = new CaseTaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CaseId = id,
|
||||
Code = request.Code,
|
||||
Description = request.Description,
|
||||
IsOpen = true,
|
||||
};
|
||||
db.Tasks.Add(task);
|
||||
db.TimelineEntries.Add(new TimelineEntryEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CaseId = id,
|
||||
At = DateTimeOffset.UtcNow,
|
||||
Kind = TimelineKind.TaskOpened,
|
||||
Description = $"Task '{task.Code}' opened: {task.Description}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new CreateTaskResponse(task.Id, task.IsOpen));
|
||||
});
|
||||
|
||||
app.MapPost("/cases/{id:guid}/tasks/{taskId:guid}/complete", async (Guid id, Guid taskId, CaseDbContext db) =>
|
||||
{
|
||||
var caseExists = await db.Cases.AnyAsync(c => c.Id == id);
|
||||
if (!caseExists)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var task = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId && t.CaseId == id);
|
||||
if (task is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
task.IsOpen = false;
|
||||
db.TimelineEntries.Add(new TimelineEntryEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CaseId = id,
|
||||
At = DateTimeOffset.UtcNow,
|
||||
Kind = TimelineKind.TaskCompleted,
|
||||
Description = $"Task '{task.Code}' completed.",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapPost("/cases/{id:guid}/closure-request", async (Guid id, CaseDbContext db) =>
|
||||
{
|
||||
var caseEntity = await db.Cases.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (caseEntity is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var hasOpenTasks = await db.Tasks.AnyAsync(t => t.CaseId == id && t.IsOpen);
|
||||
if (hasOpenTasks)
|
||||
{
|
||||
return Results.Conflict();
|
||||
}
|
||||
|
||||
caseEntity.ProcessStatus = ProcessStatus.Afgesloten;
|
||||
db.TimelineEntries.Add(new TimelineEntryEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CaseId = id,
|
||||
At = DateTimeOffset.UtcNow,
|
||||
Kind = TimelineKind.CaseClosed,
|
||||
Description = "Case closed.",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
internal record CreateCaseRequest(string CaseTypeCode, string ExternalReference, List<string>? Participants);
|
||||
|
||||
internal record CreateCaseResponse(Guid Id, string ProcessStatus);
|
||||
|
||||
internal record CaseResponse(
|
||||
Guid Id,
|
||||
string CaseTypeCode,
|
||||
string ExternalReference,
|
||||
string ProcessStatus,
|
||||
List<string> Participants);
|
||||
|
||||
internal record CreateTaskRequest(string Code, string Description);
|
||||
|
||||
internal record CreateTaskResponse(Guid TaskId, bool Open);
|
||||
|
||||
internal record TimelineEntryResponse(DateTimeOffset At, string Kind, string Description);
|
||||
|
||||
internal record TimelineResponse(List<TimelineEntryResponse> Entries);
|
||||
@@ -0,0 +1,95 @@
|
||||
services:
|
||||
legacy-db:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:-P@ssw0rd_Demo123}
|
||||
MSSQL_PID: Developer
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -Q 'SELECT 1' || exit 1"]
|
||||
interval: 10s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
new-db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: newdb
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
case-db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: caseframeworkdb
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
case-framework:
|
||||
build: ./case-framework
|
||||
environment:
|
||||
ConnectionStrings__CaseFramework: Host=case-db;Port=5432;Database=caseframeworkdb;Username=postgres;Password=postgres
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
depends_on:
|
||||
case-db:
|
||||
condition: service_healthy
|
||||
|
||||
legacy-backend:
|
||||
build:
|
||||
context: ./legacy
|
||||
dockerfile: src/Legacy.Api/Dockerfile
|
||||
environment:
|
||||
ConnectionStrings__Legacy: Server=legacy-db,1433;Database=legacydb;User Id=sa;Password=${MSSQL_SA_PASSWORD:-P@ssw0rd_Demo123};TrustServerCertificate=True
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
depends_on:
|
||||
legacy-db:
|
||||
condition: service_healthy
|
||||
|
||||
legacy-frontend:
|
||||
build:
|
||||
context: ./legacy
|
||||
dockerfile: src/Legacy.Web/Dockerfile
|
||||
environment:
|
||||
Services__LegacyApi__BaseUrl: http://legacy-backend:8080
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
depends_on:
|
||||
- legacy-backend
|
||||
|
||||
new-backend:
|
||||
build: ./new
|
||||
environment:
|
||||
ConnectionStrings__New: Host=new-db;Port=5432;Database=newdb;Username=postgres;Password=postgres
|
||||
Services__LegacyBackend__BaseUrl: http://legacy-backend:8080
|
||||
Services__CaseFramework__BaseUrl: http://case-framework:8080
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
depends_on:
|
||||
new-db:
|
||||
condition: service_healthy
|
||||
legacy-backend:
|
||||
condition: service_started
|
||||
case-framework:
|
||||
condition: service_started
|
||||
|
||||
new-frontend:
|
||||
build: ./new-frontend
|
||||
|
||||
proxy:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro,Z
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- new-frontend
|
||||
- new-backend
|
||||
- legacy-frontend
|
||||
@@ -0,0 +1,49 @@
|
||||
# ADR-001: A register decision takes effect independently of case closure
|
||||
|
||||
## Status
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
`case-framework` (seam D, a stand-in for a maintained vendor case-management
|
||||
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
|
||||
while any task on the case is still open. That rule belongs to the framework
|
||||
and is not ours to change — it is a conformist integration by design (§6).
|
||||
|
||||
The new domain's own rule is different: once an assessment (approve/reject) is
|
||||
recorded on a `RegistrationApplication`, that decision is legally in effect
|
||||
immediately. It cannot wait for an administrative task (e.g. a filing or
|
||||
notification step) to be ticked off in a separate system.
|
||||
|
||||
These two rules can genuinely conflict: an assessment can be recorded while a
|
||||
case-framework task is still open, at which point the case cannot yet be
|
||||
closed.
|
||||
|
||||
## Decision
|
||||
Recording an assessment and requesting case closure are treated as two
|
||||
separate, non-transactional steps:
|
||||
|
||||
1. `POST /api/worklist/owned/{id}/assessment` records the decision on the
|
||||
aggregate and commits it. This always succeeds if the domain invariants are
|
||||
satisfied, regardless of case-framework's task state.
|
||||
2. The handler then calls `POST /cases/{id}/closure-request` on seam D as a
|
||||
best-effort follow-up. A `409` here is an **expected, non-exceptional**
|
||||
outcome, not a failure: the assessment is not rolled back, and the response
|
||||
reports `closurePending: true` instead of an error.
|
||||
|
||||
The user-facing consequence: the outcome is decided immediately, with the UI
|
||||
showing `Besluit vastgelegd. Administratieve afsluiting in afwachting.` when
|
||||
closure is still pending. Administrative closure catches up whenever the
|
||||
remaining task is completed — a scenario this demo does not automate, since it
|
||||
is not a claim about the framework, only proof that it can lag safely.
|
||||
|
||||
## Consequences
|
||||
- The domain layer's assessment-recording method must not be coupled to
|
||||
case-framework's closure semantics — it has none of that knowledge, by
|
||||
design (New.Domain/New.Application never reference the case-framework
|
||||
client, see Architecture.Tests rules 1 and 8).
|
||||
- A case can sit in "decided but not administratively closed" indefinitely.
|
||||
That is accepted, not a bug: it is the visible cost of a conformist
|
||||
integration whose task-completion timing this system does not control.
|
||||
- No compensating transaction exists for a closure-request failure, because
|
||||
there is nothing to compensate — the assessment was correct and complete on
|
||||
its own terms.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-002: The write-through translator carries no business rules
|
||||
|
||||
## Status
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
|
||||
address, contact) from the new portal, without the new system taking
|
||||
ownership of that case. The legacy system remains the authority on this data
|
||||
until ownership is explicitly taken (§7.5).
|
||||
|
||||
It is tempting, once a translation layer exists between the portal's request
|
||||
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
|
||||
in a validation shortcut or two — "just check the postcode format here too, it
|
||||
saves a round trip." That temptation is exactly what this ADR forecloses.
|
||||
|
||||
## Decision
|
||||
`New.Infrastructure.Legacy`'s write-through translator (the type backing
|
||||
`ILegacyCaseGateway.UpdateDetailsAsync`) contains **no business rules**: no
|
||||
conditionals on request values, no validation beyond null/shape checks, no
|
||||
derived values, no defaulting. It only:
|
||||
|
||||
1. Maps the portal's 9-field request onto legacy's expected shape.
|
||||
2. Calls `PUT legacy-backend/api/aanvragen/{id}/gegevens`.
|
||||
3. Maps legacy's response — success, or **every** returned field error via the
|
||||
`veld`/`code` → portal-field-path table — back into the portal's error
|
||||
shape, including a generic fallback for any unrecognized legacy code
|
||||
(logged as a warning, never dropped or guessed at).
|
||||
|
||||
If a rule needs to be enforced on this data from the new portal, that is a
|
||||
signal the capability should be taken into ownership instead (§7.5), not
|
||||
patched into the translator.
|
||||
|
||||
## Consequences
|
||||
- The portal cannot offer a better validation experience than legacy already
|
||||
has for this seam — by design. The `Gevalideerd door het legacy systeem`
|
||||
notice on the write-through form (§8.3) exists specifically so the user
|
||||
knows why: this is the honest version of a seamless UI, not a limitation to
|
||||
hide.
|
||||
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
|
||||
request DTO and touches a `DbContext`) is only a **partial**, structural
|
||||
proxy for this constraint — and is already close to vacuous given rule 3
|
||||
(legacy DTOs are `internal` to `New.Infrastructure.Legacy` with no
|
||||
`InternalsVisibleTo` grant, so `New.Api` cannot even name them). The
|
||||
stronger claim this ADR makes — that the translator itself contains no
|
||||
conditional business logic — is a **code-review rule**, not a
|
||||
machine-enforced one. We say so here rather than implying test coverage
|
||||
that does not exist.
|
||||
- Any future temptation to "just add one small check" in the translator
|
||||
should instead be read as a signal to take that capability into ownership.
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR-003: Ownership is taken per case for now — bulk migration is a later, separate capability
|
||||
|
||||
## Status
|
||||
Accepted (interim). Superseded in part once bulk migration tooling (see
|
||||
"Future work" below) ships.
|
||||
|
||||
## Context
|
||||
The end state for at least some processes — registration cases among them —
|
||||
is a **bulk cutover**: migrate the whole remaining population in one
|
||||
operation and retire the legacy path for that process on a clean date. That
|
||||
is a real, wanted outcome, not something this design argues against.
|
||||
|
||||
What this system cannot do is wait for that bulk-migration tooling to exist
|
||||
before shipping anything of business value. Building a safe bulk migration
|
||||
requires solving problems this demo deliberately defers: what happens to rows
|
||||
that fail adoption (four such failure modes already exist in the seed data —
|
||||
contact, BSN, motivation, and partial-address invariant violations), how a
|
||||
partially-failed batch is reported and retried, and how the cutover is
|
||||
scheduled and communicated. None of that should block getting the read ACL,
|
||||
write-through ACL, and take-ownership mechanics themselves live and earning
|
||||
their keep.
|
||||
|
||||
## Decision
|
||||
Ship now with ownership taken **one legacy case at a time**, via
|
||||
`POST /api/worklist/legacy/{aanvraagId}/take-ownership` (§7.5), triggered by
|
||||
an explicit user action in the portal. This is the interim mechanism, not the
|
||||
final one for every process.
|
||||
|
||||
This is deliberately the right building block either way:
|
||||
- It is the same adoption logic (mapping, invariant validation, case-framework
|
||||
correlation, atomic persistence, migratie-vlag flip) that a future bulk tool
|
||||
would need to call in a loop — building it per-case first means the bulk
|
||||
tool is an orchestration layer on top of already-proven logic, not a
|
||||
parallel implementation to keep in sync.
|
||||
- It gives a real, visible answer today for what a bulk migration would
|
||||
otherwise discover the hard way: which legacy rows fail adoption and why
|
||||
(surfaced here as a named `422` per case, not a batch-job log line).
|
||||
- The read ACL (seam A) and write-through ACL (seam B) must work correctly
|
||||
for a partially-adopted population regardless of how adoption happens —
|
||||
that requirement doesn't change once bulk tooling exists.
|
||||
|
||||
## Consequences
|
||||
- Until bulk tooling exists, full legacy retirement for a process happens
|
||||
case-by-case, which is slower than a scheduled cutover — accepted as the
|
||||
cost of shipping the seam mechanics now rather than waiting.
|
||||
- Reversal (§7.6) stays per-case and gated on `domain_writes_since` for the
|
||||
same reason a bulk reversal would be unsafe absent a sync
|
||||
(`docs/sync-not-implemented.md`): undoing adoption after edits would
|
||||
silently discard them.
|
||||
- This demo's non-goals (§3) exclude building the bulk migration tool itself
|
||||
— that's future work, not a rejected idea.
|
||||
|
||||
## Future work
|
||||
A bulk migration tool for a given process (e.g. registration cases) can reuse
|
||||
the same take-ownership handler per legacy id, adding: pre-flight reporting of
|
||||
which rows would fail adoption and why (so the four invariant-failure classes
|
||||
seen here are triaged before cutover, not discovered during it), a scheduled
|
||||
cutover window, and a decision on whether failed rows block the cutover or are
|
||||
carved out and finished by hand.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Legacy.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Maps to dbo.AANVR. Property names deliberately mirror the legacy column
|
||||
/// vocabulary (abbreviated Dutch) rather than modern domain terms - that
|
||||
/// translation is the downstream anti-corruption layer's job, not ours.
|
||||
/// </summary>
|
||||
public class Aanvraag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Bsn { get; set; } = "";
|
||||
public string Naam { get; set; } = "";
|
||||
public string? Voorl { get; set; }
|
||||
public string? AdresStr { get; set; }
|
||||
public string? AdresNr { get; set; }
|
||||
public string? AdresPc { get; set; }
|
||||
public string? AdresPl { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Telnr { get; set; }
|
||||
public string CorrKanaal { get; set; } = "P";
|
||||
public string StatCd { get; set; } = "O";
|
||||
public string? DiplCd { get; set; }
|
||||
public string? DiplLand { get; set; }
|
||||
public DateOnly? DiplDat { get; set; }
|
||||
public DateOnly DatOntv { get; set; }
|
||||
public DateOnly? DatBeoord { get; set; }
|
||||
public string? BeoordRes { get; set; }
|
||||
public string? BeoordMotiv { get; set; }
|
||||
public bool Migrated { get; set; }
|
||||
public DateTime MutDat { get; set; }
|
||||
public string MutUser { get; set; } = "seed";
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Legacy.Api.Data;
|
||||
|
||||
public class LegacyDbContext(DbContextOptions<LegacyDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Aanvraag> Aanvragen => Set<Aanvraag>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Aanvraag>(e =>
|
||||
{
|
||||
e.ToTable("AANVR", "dbo");
|
||||
e.HasKey(a => a.Id);
|
||||
|
||||
e.Property(a => a.Id).HasColumnName("AANVR_ID").ValueGeneratedOnAdd();
|
||||
e.Property(a => a.Bsn).HasColumnName("BSN").HasColumnType("char(9)").IsRequired();
|
||||
e.Property(a => a.Naam).HasColumnName("NAAM").HasMaxLength(60).IsRequired();
|
||||
e.Property(a => a.Voorl).HasColumnName("VOORL").HasMaxLength(10);
|
||||
e.Property(a => a.AdresStr).HasColumnName("ADRES_STR").HasMaxLength(80);
|
||||
e.Property(a => a.AdresNr).HasColumnName("ADRES_NR").HasMaxLength(10);
|
||||
e.Property(a => a.AdresPc).HasColumnName("ADRES_PC").HasColumnType("char(6)");
|
||||
e.Property(a => a.AdresPl).HasColumnName("ADRES_PL").HasMaxLength(60);
|
||||
e.Property(a => a.Email).HasColumnName("EMAIL").HasMaxLength(120);
|
||||
e.Property(a => a.Telnr).HasColumnName("TELNR").HasMaxLength(20);
|
||||
e.Property(a => a.CorrKanaal).HasColumnName("CORR_KANAAL").HasColumnType("char(1)")
|
||||
.HasDefaultValue("P").IsRequired();
|
||||
e.Property(a => a.StatCd).HasColumnName("STAT_CD").HasColumnType("char(1)").IsRequired();
|
||||
e.Property(a => a.DiplCd).HasColumnName("DIPL_CD").HasMaxLength(10);
|
||||
e.Property(a => a.DiplLand).HasColumnName("DIPL_LAND").HasColumnType("char(2)");
|
||||
e.Property(a => a.DiplDat).HasColumnName("DIPL_DAT").HasColumnType("date");
|
||||
e.Property(a => a.DatOntv).HasColumnName("DAT_ONTV").HasColumnType("date").IsRequired();
|
||||
e.Property(a => a.DatBeoord).HasColumnName("DAT_BEOORD").HasColumnType("date");
|
||||
e.Property(a => a.BeoordRes).HasColumnName("BEOORD_RES").HasColumnType("char(1)");
|
||||
e.Property(a => a.BeoordMotiv).HasColumnName("BEOORD_MOTIV").HasMaxLength(500);
|
||||
e.Property(a => a.Migrated).HasColumnName("MIGRATED").HasDefaultValue(false).IsRequired();
|
||||
e.Property(a => a.MutDat).HasColumnName("MUT_DAT").HasColumnType("datetime2").IsRequired();
|
||||
e.Property(a => a.MutUser).HasColumnName("MUT_USER").HasMaxLength(30).IsRequired();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Legacy.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent seed of the 12 demo AANVR rows at ids 1001-1012. Safe to run on
|
||||
/// every startup: it only inserts when the table is empty.
|
||||
/// </summary>
|
||||
public static class LegacySeeder
|
||||
{
|
||||
public static async Task SeedAsync(LegacyDbContext db)
|
||||
{
|
||||
if (await db.Aanvragen.AnyAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = new List<Aanvraag>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1001, Bsn = "195751814", Naam = "de Vries", Voorl = "A.",
|
||||
AdresStr = "Kerkweg", AdresNr = "12", AdresPc = "3512JK", AdresPl = "Utrecht",
|
||||
Email = "anna.devries@example.nl", Telnr = "+31 6 12345678", CorrKanaal = "P",
|
||||
StatCd = "O", DiplCd = "WO-ECO", DiplLand = "DE", DiplDat = new DateOnly(2015, 6, 20),
|
||||
DatOntv = new DateOnly(2026, 3, 10),
|
||||
MutDat = new DateTime(2026, 3, 10, 9, 15, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1002, Bsn = "254488808", Naam = "Jansen", Voorl = "P.",
|
||||
AdresStr = "Prinsengracht", AdresNr = "45", AdresPc = "1016HB", AdresPl = "Amsterdam",
|
||||
Email = "piet.jansen@example.nl", Telnr = "020 1234567", CorrKanaal = "P",
|
||||
StatCd = "B", DiplCd = "HBO-VPK", DiplLand = "BE", DiplDat = new DateOnly(2012, 7, 1),
|
||||
DatOntv = new DateOnly(2025, 11, 20), DatBeoord = new DateOnly(2025, 12, 5),
|
||||
BeoordRes = "G",
|
||||
BeoordMotiv = "Aanvraag voldoet aan alle diploma-eisen en documentatie is compleet.",
|
||||
MutDat = new DateTime(2025, 12, 5, 11, 0, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1003, Bsn = "862102455", Naam = "El Amrani", Voorl = "F.",
|
||||
AdresStr = "Molenstraat", AdresNr = "8", AdresPc = "5611EM", AdresPl = "Eindhoven",
|
||||
Email = null, Telnr = "+31 6 87654321", CorrKanaal = "E",
|
||||
StatCd = "O", DiplCd = "WO-ING", DiplLand = "MA", DiplDat = new DateOnly(2018, 6, 15),
|
||||
DatOntv = new DateOnly(2026, 5, 2),
|
||||
MutDat = new DateTime(2026, 5, 2, 8, 45, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1004, Bsn = "501061964", Naam = "Bakker", Voorl = "L.",
|
||||
AdresStr = "Nieuwstraat", AdresNr = "22", AdresPc = "4811XB", AdresPl = "Breda",
|
||||
Email = "lisa.bakker@example.nl", Telnr = "076 5432109", CorrKanaal = "P",
|
||||
StatCd = "X", DiplCd = "HBO-ICT", DiplLand = "GB", DiplDat = new DateOnly(2010, 5, 10),
|
||||
DatOntv = new DateOnly(2025, 8, 14),
|
||||
MutDat = new DateTime(2025, 8, 20, 14, 30, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1005, Bsn = "000000000", Naam = "Visser", Voorl = "J.",
|
||||
AdresStr = "Hoofdstraat", AdresNr = "3", AdresPc = "9711AA", AdresPl = "Groningen",
|
||||
Email = "jan.visser@example.nl", Telnr = "+31 6 11223344", CorrKanaal = "P",
|
||||
StatCd = "O", DiplCd = "MBO-ZORG", DiplLand = "PL", DiplDat = new DateOnly(2019, 9, 1),
|
||||
DatOntv = new DateOnly(2026, 2, 18),
|
||||
MutDat = new DateTime(2026, 2, 18, 10, 5, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1006, Bsn = "184513418", Naam = "Okonkwo", Voorl = "C.",
|
||||
AdresStr = "Zuidplein", AdresNr = "14", AdresPc = "3083CN", AdresPl = "Rotterdam",
|
||||
Email = "c.okonkwo@example.nl", Telnr = "+31 6 22334455", CorrKanaal = "P",
|
||||
StatCd = "B", DiplCd = "WO-GEN", DiplLand = "NG", DiplDat = new DateOnly(2016, 7, 1),
|
||||
DatOntv = new DateOnly(2025, 10, 1), DatBeoord = new DateOnly(2025, 10, 15),
|
||||
BeoordRes = "G", BeoordMotiv = "Akkoord",
|
||||
MutDat = new DateTime(2025, 10, 15, 13, 20, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1007, Bsn = "682298268", Naam = "Smit", Voorl = "R.",
|
||||
AdresStr = "Kerkstraat", AdresNr = null, AdresPc = "2611GA", AdresPl = "Delft",
|
||||
Email = "r.smit@example.nl", Telnr = "+31 6 33445566", CorrKanaal = "P",
|
||||
StatCd = "O", DiplCd = "HBO-BWI", DiplLand = "TR", DiplDat = new DateOnly(2014, 6, 30),
|
||||
DatOntv = new DateOnly(2026, 4, 22),
|
||||
MutDat = new DateTime(2026, 4, 22, 15, 50, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1008, Bsn = "794413821", Naam = "Vermeulen", Voorl = "M.",
|
||||
AdresStr = "Julianastraat", AdresNr = "31", AdresPc = "6511PJ", AdresPl = "Nijmegen",
|
||||
Email = "m.vermeulen@example.nl", Telnr = "+31 6 44556677", CorrKanaal = "P",
|
||||
StatCd = "O", DiplCd = "WO-RECHT", DiplLand = "FR", DiplDat = new DateOnly(2013, 6, 25),
|
||||
DatOntv = new DateOnly(2025, 9, 12),
|
||||
MutDat = new DateTime(2025, 9, 12, 9, 0, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1009, Bsn = "469486879", Naam = "Willems", Voorl = "S.",
|
||||
AdresStr = "Grote Markt", AdresNr = "2", AdresPc = "2511BE", AdresPl = "Den Haag",
|
||||
Email = "s.willems@example.nl", Telnr = "+31 6 55667788", CorrKanaal = "E",
|
||||
StatCd = "B", DiplCd = "HBO-ECO", DiplLand = "ES", DiplDat = new DateOnly(2017, 7, 5),
|
||||
DatOntv = new DateOnly(2025, 11, 3), DatBeoord = new DateOnly(2025, 11, 25),
|
||||
BeoordRes = "G", BeoordMotiv = "Diploma is gewaardeerd conform de geldende richtlijnen.",
|
||||
MutDat = new DateTime(2025, 11, 25, 16, 10, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1010, Bsn = "349496213", Naam = "Peeters", Voorl = "K.",
|
||||
AdresStr = "Stationsplein", AdresNr = "10", AdresPc = "5611AZ", AdresPl = "Eindhoven",
|
||||
Email = "k.peeters@example.nl", Telnr = "040 1122334", CorrKanaal = "P",
|
||||
StatCd = "A", DiplCd = "MBO-TECH", DiplLand = "IT", DiplDat = new DateOnly(2011, 6, 18),
|
||||
DatOntv = new DateOnly(2025, 8, 20), DatBeoord = new DateOnly(2025, 9, 10),
|
||||
BeoordRes = "G", BeoordMotiv = "Alle documenten zijn gecontroleerd en akkoord bevonden.",
|
||||
MutDat = new DateTime(2025, 9, 10, 10, 40, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1011, Bsn = "944293797", Naam = "Dekker", Voorl = "T.",
|
||||
AdresStr = "Torenlaan", AdresNr = "18", AdresPc = "7511AB", AdresPl = "Enschede",
|
||||
Email = null, Telnr = "+31 6 66778899", CorrKanaal = "E",
|
||||
StatCd = "O", DiplCd = "WO-PSY", DiplLand = "PT", DiplDat = new DateOnly(2020, 6, 1),
|
||||
DatOntv = new DateOnly(2026, 1, 15),
|
||||
MutDat = new DateTime(2026, 1, 15, 12, 25, 0), MutUser = "seed",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1012, Bsn = "380075131", Naam = "Mulder", Voorl = "H.",
|
||||
AdresStr = "Beukenlaan", AdresNr = "4", AdresPc = "8011MN", AdresPl = "Zwolle",
|
||||
Email = "h.mulder@example.nl", Telnr = "038 9988776", CorrKanaal = "P",
|
||||
StatCd = "B", DiplCd = "HBO-EDU", DiplLand = "RO", DiplDat = new DateOnly(2015, 7, 14),
|
||||
DatOntv = new DateOnly(2025, 12, 1), DatBeoord = new DateOnly(2025, 12, 20),
|
||||
BeoordRes = "A",
|
||||
BeoordMotiv = "Buitenlands diploma komt niet overeen met een erkend Nederlands diploma-niveau.",
|
||||
MutDat = new DateTime(2025, 12, 20, 14, 5, 0), MutUser = "seed",
|
||||
},
|
||||
};
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
await db.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.AANVR ON");
|
||||
db.Aanvragen.AddRange(rows);
|
||||
await db.SaveChangesAsync();
|
||||
await db.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.AANVR OFF");
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY src/Legacy.Api/ src/Legacy.Api/
|
||||
# restore+publish combined in one RUN/layer: podman/buildah has a known issue
|
||||
# where the NuGet global-packages cache uses hardlinks that break when a
|
||||
# restore layer and a later --no-restore publish layer are committed
|
||||
# separately, surfacing as a false "package not found" error.
|
||||
RUN dotnet restore src/Legacy.Api/Legacy.Api.csproj && \
|
||||
dotnet publish src/Legacy.Api/Legacy.Api.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "Legacy.Api.dll"]
|
||||
@@ -0,0 +1,127 @@
|
||||
using Legacy.Api.Data;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Legacy.Api.Endpoints;
|
||||
|
||||
public static class AanvragenEndpoints
|
||||
{
|
||||
public static void MapAanvragenEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/aanvragen");
|
||||
|
||||
group.MapGet("", GetAll);
|
||||
group.MapGet("/{id:int}", GetById);
|
||||
group.MapPut("/{id:int}/gegevens", PutGegevens);
|
||||
group.MapPost("/{id:int}/beoordeling", PostBeoordeling);
|
||||
group.MapPut("/{id:int}/migratie-vlag", PutMigratieVlag);
|
||||
}
|
||||
|
||||
private static async Task<Ok<List<Aanvraag>>> GetAll(
|
||||
LegacyDbContext db, string? zoek, string? status)
|
||||
{
|
||||
var query = db.Aanvragen.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(zoek))
|
||||
{
|
||||
var term = zoek.ToLower();
|
||||
query = query.Where(a => a.Naam.ToLower().Contains(term) || a.Bsn.ToLower().Contains(term));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query = query.Where(a => a.StatCd == status);
|
||||
}
|
||||
|
||||
var result = await query.OrderBy(a => a.Id).ToListAsync();
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<Aanvraag>, NotFound>> GetById(LegacyDbContext db, int id)
|
||||
{
|
||||
var aanvraag = await db.Aanvragen.FindAsync(id);
|
||||
return aanvraag is null ? TypedResults.NotFound() : TypedResults.Ok(aanvraag);
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, BadRequest<ValidationErrorResponse>, NotFound, Conflict<MigratedResponse>>> PutGegevens(
|
||||
LegacyDbContext db, int id, GegevensInput input)
|
||||
{
|
||||
var aanvraag = await db.Aanvragen.FindAsync(id);
|
||||
if (aanvraag is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (aanvraag.Migrated)
|
||||
{
|
||||
return TypedResults.Conflict(
|
||||
new MigratedResponse("Deze aanvraag wordt beheerd in het nieuwe portaal."));
|
||||
}
|
||||
|
||||
var errors = GegevensValidator.Validate(input);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return TypedResults.BadRequest(new ValidationErrorResponse(errors));
|
||||
}
|
||||
|
||||
aanvraag.Naam = input.Surname;
|
||||
aanvraag.Voorl = input.Initials;
|
||||
aanvraag.AdresStr = input.Address?.Street;
|
||||
aanvraag.AdresNr = input.Address?.Number;
|
||||
aanvraag.AdresPc = input.Address?.PostalCode;
|
||||
aanvraag.AdresPl = input.Address?.City;
|
||||
aanvraag.Email = input.Email;
|
||||
aanvraag.Telnr = input.Phone;
|
||||
aanvraag.CorrKanaal = string.Equals(input.PreferredChannel, "Email", StringComparison.OrdinalIgnoreCase)
|
||||
? "E"
|
||||
: "P";
|
||||
aanvraag.MutDat = DateTime.Now;
|
||||
aanvraag.MutUser = "systeem";
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound, Conflict<MigratedResponse>>> PostBeoordeling(
|
||||
LegacyDbContext db, int id, BeoordelingInput input)
|
||||
{
|
||||
var aanvraag = await db.Aanvragen.FindAsync(id);
|
||||
if (aanvraag is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (aanvraag.Migrated)
|
||||
{
|
||||
return TypedResults.Conflict(
|
||||
new MigratedResponse("Deze aanvraag wordt beheerd in het nieuwe portaal."));
|
||||
}
|
||||
|
||||
aanvraag.StatCd = "B";
|
||||
aanvraag.BeoordRes = input.Res;
|
||||
aanvraag.BeoordMotiv = input.Motiv;
|
||||
aanvraag.DatBeoord = DateOnly.FromDateTime(DateTime.Now);
|
||||
aanvraag.MutDat = DateTime.Now;
|
||||
aanvraag.MutUser = "systeem";
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound>> PutMigratieVlag(
|
||||
LegacyDbContext db, int id, MigratieVlagInput input)
|
||||
{
|
||||
var aanvraag = await db.Aanvragen.FindAsync(id);
|
||||
if (aanvraag is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
aanvraag.Migrated = input.Migrated;
|
||||
aanvraag.MutDat = DateTime.Now;
|
||||
aanvraag.MutUser = "systeem";
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Legacy.Api.Endpoints;
|
||||
|
||||
public record AdresInput(string? Street, string? Number, string? PostalCode, string? City);
|
||||
|
||||
public record GegevensInput(
|
||||
string Surname,
|
||||
string? Initials,
|
||||
AdresInput? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel);
|
||||
|
||||
public record BeoordelingInput(string Res, string Motiv);
|
||||
|
||||
public record MigratieVlagInput(bool Migrated);
|
||||
|
||||
public record ValidationError(string Veld, string Code, string Melding);
|
||||
|
||||
public record ValidationErrorResponse(IReadOnlyList<ValidationError> Errors);
|
||||
|
||||
public record MigratedResponse(string Message);
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Legacy.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the "gegevens" (particulars) command against the legacy field
|
||||
/// rules. Collects every violation instead of stopping at the first one.
|
||||
/// </summary>
|
||||
public static partial class GegevensValidator
|
||||
{
|
||||
public static List<ValidationError> Validate(GegevensInput input)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Surname))
|
||||
{
|
||||
errors.Add(new ValidationError("NAAM", "NAAM_VERPLICHT", "Achternaam is verplicht"));
|
||||
}
|
||||
else if (input.Surname.Length > 60)
|
||||
{
|
||||
errors.Add(new ValidationError("NAAM", "NAAM_TE_LANG", "Achternaam is te lang"));
|
||||
}
|
||||
|
||||
var street = input.Address?.Street;
|
||||
var number = input.Address?.Number;
|
||||
var postalCode = input.Address?.PostalCode;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(street) && string.IsNullOrWhiteSpace(number))
|
||||
{
|
||||
errors.Add(new ValidationError("ADRES_NR", "HUISNR_VERPLICHT", "Huisnummer is verplicht"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(postalCode) && !PostcodeRegex().IsMatch(postalCode))
|
||||
{
|
||||
errors.Add(new ValidationError("ADRES_PC", "POSTCODE_ONGELDIG", "Postcode ongeldig"));
|
||||
}
|
||||
|
||||
var wantsEmailChannel = string.Equals(input.PreferredChannel, "Email", StringComparison.OrdinalIgnoreCase);
|
||||
if (wantsEmailChannel && string.IsNullOrWhiteSpace(input.Email))
|
||||
{
|
||||
errors.Add(new ValidationError(
|
||||
"EMAIL", "EMAIL_VERPLICHT_BIJ_KANAAL", "E-mailadres is verplicht bij communicatiekanaal e-mail"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Email) && !EmailRegex().IsMatch(input.Email))
|
||||
{
|
||||
errors.Add(new ValidationError("EMAIL", "EMAIL_ONGELDIG", "E-mailadres is ongeldig"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Phone) && !PhoneRegex().IsMatch(input.Phone))
|
||||
{
|
||||
errors.Add(new ValidationError("TELNR", "TELNR_ONGELDIG", "Telefoonnummer is ongeldig"));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[0-9]{4}[A-Z]{2}$")]
|
||||
private static partial Regex PostcodeRegex();
|
||||
|
||||
[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$")]
|
||||
private static partial Regex EmailRegex();
|
||||
|
||||
[GeneratedRegex(@"^[0-9 +]+$")]
|
||||
private static partial Regex PhoneRegex();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!--
|
||||
Microsoft.Data.SqlClient requires real culture data (it resolves
|
||||
culture info while opening a connection) - invariant globalization mode
|
||||
makes it throw CultureNotFoundException on every connection attempt.
|
||||
The base runtime image (non-Alpine, non-chiseled) ships ICU, so turning
|
||||
this off just works.
|
||||
-->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,147 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Legacy.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Legacy.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(LegacyDbContext))]
|
||||
[Migration("20260730160055_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Legacy.Api.Data.Aanvraag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasColumnName("AANVR_ID");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AdresNr")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)")
|
||||
.HasColumnName("ADRES_NR");
|
||||
|
||||
b.Property<string>("AdresPc")
|
||||
.HasColumnType("char(6)")
|
||||
.HasColumnName("ADRES_PC");
|
||||
|
||||
b.Property<string>("AdresPl")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("nvarchar(60)")
|
||||
.HasColumnName("ADRES_PL");
|
||||
|
||||
b.Property<string>("AdresStr")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)")
|
||||
.HasColumnName("ADRES_STR");
|
||||
|
||||
b.Property<string>("BeoordMotiv")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasColumnName("BEOORD_MOTIV");
|
||||
|
||||
b.Property<string>("BeoordRes")
|
||||
.HasColumnType("char(1)")
|
||||
.HasColumnName("BEOORD_RES");
|
||||
|
||||
b.Property<string>("Bsn")
|
||||
.IsRequired()
|
||||
.HasColumnType("char(9)")
|
||||
.HasColumnName("BSN");
|
||||
|
||||
b.Property<string>("CorrKanaal")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(1)")
|
||||
.HasDefaultValue("P")
|
||||
.HasColumnName("CORR_KANAAL");
|
||||
|
||||
b.Property<DateOnly?>("DatBeoord")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("DAT_BEOORD");
|
||||
|
||||
b.Property<DateOnly>("DatOntv")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("DAT_ONTV");
|
||||
|
||||
b.Property<string>("DiplCd")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)")
|
||||
.HasColumnName("DIPL_CD");
|
||||
|
||||
b.Property<DateOnly?>("DiplDat")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("DIPL_DAT");
|
||||
|
||||
b.Property<string>("DiplLand")
|
||||
.HasColumnType("char(2)")
|
||||
.HasColumnName("DIPL_LAND");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)")
|
||||
.HasColumnName("EMAIL");
|
||||
|
||||
b.Property<bool>("Migrated")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("MIGRATED");
|
||||
|
||||
b.Property<DateTime>("MutDat")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("MUT_DAT");
|
||||
|
||||
b.Property<string>("MutUser")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("nvarchar(30)")
|
||||
.HasColumnName("MUT_USER");
|
||||
|
||||
b.Property<string>("Naam")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("nvarchar(60)")
|
||||
.HasColumnName("NAAM");
|
||||
|
||||
b.Property<string>("StatCd")
|
||||
.IsRequired()
|
||||
.HasColumnType("char(1)")
|
||||
.HasColumnName("STAT_CD");
|
||||
|
||||
b.Property<string>("Telnr")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)")
|
||||
.HasColumnName("TELNR");
|
||||
|
||||
b.Property<string>("Voorl")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)")
|
||||
.HasColumnName("VOORL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AANVR", "dbo");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Legacy.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "dbo");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AANVR",
|
||||
schema: "dbo",
|
||||
columns: table => new
|
||||
{
|
||||
AANVR_ID = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
BSN = table.Column<string>(type: "char(9)", nullable: false),
|
||||
NAAM = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
|
||||
VOORL = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
ADRES_STR = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: true),
|
||||
ADRES_NR = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
ADRES_PC = table.Column<string>(type: "char(6)", nullable: true),
|
||||
ADRES_PL = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: true),
|
||||
EMAIL = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: true),
|
||||
TELNR = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
CORR_KANAAL = table.Column<string>(type: "char(1)", nullable: false, defaultValue: "P"),
|
||||
STAT_CD = table.Column<string>(type: "char(1)", nullable: false),
|
||||
DIPL_CD = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
DIPL_LAND = table.Column<string>(type: "char(2)", nullable: true),
|
||||
DIPL_DAT = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
DAT_ONTV = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
DAT_BEOORD = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
BEOORD_RES = table.Column<string>(type: "char(1)", nullable: true),
|
||||
BEOORD_MOTIV = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
MIGRATED = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
MUT_DAT = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
MUT_USER = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AANVR", x => x.AANVR_ID);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AANVR",
|
||||
schema: "dbo");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Legacy.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Legacy.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(LegacyDbContext))]
|
||||
partial class LegacyDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Legacy.Api.Data.Aanvraag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasColumnName("AANVR_ID");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AdresNr")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)")
|
||||
.HasColumnName("ADRES_NR");
|
||||
|
||||
b.Property<string>("AdresPc")
|
||||
.HasColumnType("char(6)")
|
||||
.HasColumnName("ADRES_PC");
|
||||
|
||||
b.Property<string>("AdresPl")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("nvarchar(60)")
|
||||
.HasColumnName("ADRES_PL");
|
||||
|
||||
b.Property<string>("AdresStr")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)")
|
||||
.HasColumnName("ADRES_STR");
|
||||
|
||||
b.Property<string>("BeoordMotiv")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasColumnName("BEOORD_MOTIV");
|
||||
|
||||
b.Property<string>("BeoordRes")
|
||||
.HasColumnType("char(1)")
|
||||
.HasColumnName("BEOORD_RES");
|
||||
|
||||
b.Property<string>("Bsn")
|
||||
.IsRequired()
|
||||
.HasColumnType("char(9)")
|
||||
.HasColumnName("BSN");
|
||||
|
||||
b.Property<string>("CorrKanaal")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(1)")
|
||||
.HasDefaultValue("P")
|
||||
.HasColumnName("CORR_KANAAL");
|
||||
|
||||
b.Property<DateOnly?>("DatBeoord")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("DAT_BEOORD");
|
||||
|
||||
b.Property<DateOnly>("DatOntv")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("DAT_ONTV");
|
||||
|
||||
b.Property<string>("DiplCd")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)")
|
||||
.HasColumnName("DIPL_CD");
|
||||
|
||||
b.Property<DateOnly?>("DiplDat")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("DIPL_DAT");
|
||||
|
||||
b.Property<string>("DiplLand")
|
||||
.HasColumnType("char(2)")
|
||||
.HasColumnName("DIPL_LAND");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)")
|
||||
.HasColumnName("EMAIL");
|
||||
|
||||
b.Property<bool>("Migrated")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("MIGRATED");
|
||||
|
||||
b.Property<DateTime>("MutDat")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("MUT_DAT");
|
||||
|
||||
b.Property<string>("MutUser")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("nvarchar(30)")
|
||||
.HasColumnName("MUT_USER");
|
||||
|
||||
b.Property<string>("Naam")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("nvarchar(60)")
|
||||
.HasColumnName("NAAM");
|
||||
|
||||
b.Property<string>("StatCd")
|
||||
.IsRequired()
|
||||
.HasColumnType("char(1)")
|
||||
.HasColumnName("STAT_CD");
|
||||
|
||||
b.Property<string>("Telnr")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)")
|
||||
.HasColumnName("TELNR");
|
||||
|
||||
b.Property<string>("Voorl")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)")
|
||||
.HasColumnName("VOORL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AANVR", "dbo");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Legacy.Api.Data;
|
||||
using Legacy.Api.Endpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddDbContext<LegacyDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("Legacy")));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<LegacyDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await LegacySeeder.SeedAsync(db);
|
||||
}
|
||||
|
||||
app.MapAanvragenEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Legacy.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the JSON shape returned by Legacy.Api - legacy column vocabulary,
|
||||
/// camelCase over the wire, matched here case-insensitively.
|
||||
/// </summary>
|
||||
public class AanvraagDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Bsn { get; set; } = "";
|
||||
public string Naam { get; set; } = "";
|
||||
public string? Voorl { get; set; }
|
||||
public string? AdresStr { get; set; }
|
||||
public string? AdresNr { get; set; }
|
||||
public string? AdresPc { get; set; }
|
||||
public string? AdresPl { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Telnr { get; set; }
|
||||
public string CorrKanaal { get; set; } = "P";
|
||||
public string StatCd { get; set; } = "O";
|
||||
public string? DiplCd { get; set; }
|
||||
public string? DiplLand { get; set; }
|
||||
public DateOnly? DiplDat { get; set; }
|
||||
public DateOnly DatOntv { get; set; }
|
||||
public DateOnly? DatBeoord { get; set; }
|
||||
public string? BeoordRes { get; set; }
|
||||
public string? BeoordMotiv { get; set; }
|
||||
public bool Migrated { get; set; }
|
||||
public DateTime MutDat { get; set; }
|
||||
public string MutUser { get; set; } = "";
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Legacy.Web;
|
||||
|
||||
public enum BeoordelingUitkomst
|
||||
{
|
||||
Success,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thin HTTP client for Legacy.Api. Legacy.Web never touches the database
|
||||
/// directly - it only talks to the backend over HTTP, same as any other
|
||||
/// caller.
|
||||
/// </summary>
|
||||
public class LegacyApiClient(HttpClient httpClient)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<List<AanvraagDto>> GetAllAsync(string? zoek, string? status)
|
||||
{
|
||||
var query = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(zoek))
|
||||
{
|
||||
query.Add($"zoek={Uri.EscapeDataString(zoek)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
|
||||
var url = "/api/aanvragen" + (query.Count > 0 ? "?" + string.Join("&", query) : "");
|
||||
var result = await httpClient.GetFromJsonAsync<List<AanvraagDto>>(url, JsonOptions);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task<AanvraagDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var response = await httpClient.GetAsync($"/api/aanvragen/{id}");
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<AanvraagDto>(JsonOptions);
|
||||
}
|
||||
|
||||
public async Task<BeoordelingUitkomst> SubmitBeoordelingAsync(int id, string res, string motiv)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync(
|
||||
$"/api/aanvragen/{id}/beoordeling", new { res, motiv });
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return BeoordelingUitkomst.Conflict;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return BeoordelingUitkomst.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@page "/legacy/aanvraag/{id:int}/beoordeling"
|
||||
@model Legacy.Web.Pages.BeoordelingModel
|
||||
|
||||
<h1>Beoordeling aanvraag #@Model.Aanvraag.Id</h1>
|
||||
|
||||
@if (Model.Ingediend)
|
||||
{
|
||||
<div class="melding">
|
||||
<p>De beoordeling is verwerkt.</p>
|
||||
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Terug naar aanvraag</a></p>
|
||||
</div>
|
||||
}
|
||||
else if (Model.Aanvraag.Migrated)
|
||||
{
|
||||
<div class="melding">
|
||||
<p>Deze aanvraag wordt beheerd in het nieuwe portaal.</p>
|
||||
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Naar nieuw portaal</a></p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Model.Conflict)
|
||||
{
|
||||
<div class="melding">
|
||||
<p>Deze aanvraag wordt inmiddels beheerd in het nieuwe portaal. De beoordeling is niet opgeslagen.</p>
|
||||
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Naar nieuw portaal</a></p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<fieldset>
|
||||
<legend>Aanvraaggegevens</legend>
|
||||
<p><label>Naam:</label> @Model.Aanvraag.Naam @Model.Aanvraag.Voorl</p>
|
||||
<p><label>BSN:</label> @Model.Aanvraag.Bsn</p>
|
||||
<p><label>Ontvangen:</label> @Model.Aanvraag.DatOntv.ToString("dd-MM-yyyy")</p>
|
||||
</fieldset>
|
||||
|
||||
<form method="post">
|
||||
<fieldset>
|
||||
<legend>Beoordeling</legend>
|
||||
<p>
|
||||
<label for="res">Uitkomst:</label>
|
||||
<select id="res" name="Res">
|
||||
<option value="G" selected="@(Model.Res == "G")">Goedgekeurd</option>
|
||||
<option value="A" selected="@(Model.Res == "A")">Afgewezen</option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="motiv">Motivatie:</label><br />
|
||||
<textarea id="motiv" name="Motiv" rows="5" cols="60">@Model.Motiv</textarea>
|
||||
</p>
|
||||
<p>
|
||||
<input type="submit" value="Beoordeling opslaan" />
|
||||
</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Legacy.Web.Pages;
|
||||
|
||||
public class BeoordelingModel(LegacyApiClient client) : PageModel
|
||||
{
|
||||
public AanvraagDto Aanvraag { get; set; } = null!;
|
||||
|
||||
public bool Ingediend { get; set; }
|
||||
|
||||
public bool Conflict { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public string Res { get; set; } = "G";
|
||||
|
||||
[BindProperty]
|
||||
public string Motiv { get; set; } = "";
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
var aanvraag = await client.GetByIdAsync(id);
|
||||
if (aanvraag is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Aanvraag = aanvraag;
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync(int id)
|
||||
{
|
||||
var aanvraag = await client.GetByIdAsync(id);
|
||||
if (aanvraag is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Aanvraag = aanvraag;
|
||||
|
||||
if (Aanvraag.Migrated)
|
||||
{
|
||||
// Blocked page is rendered from the razor markup; the write
|
||||
// endpoint is never called for a migrated case.
|
||||
return Page();
|
||||
}
|
||||
|
||||
var uitkomst = await client.SubmitBeoordelingAsync(id, Res, Motiv);
|
||||
if (uitkomst == BeoordelingUitkomst.Conflict)
|
||||
{
|
||||
Conflict = true;
|
||||
Aanvraag = (await client.GetByIdAsync(id))!;
|
||||
return Page();
|
||||
}
|
||||
|
||||
Ingediend = true;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@page "/legacy"
|
||||
@model Legacy.Web.Pages.IndexModel
|
||||
|
||||
<h1>Aanvragen diplomawaardering</h1>
|
||||
|
||||
<form method="get">
|
||||
<label for="zoek">Zoeken (naam/BSN):</label>
|
||||
<input type="text" id="zoek" name="Zoek" value="@Model.Zoek" />
|
||||
|
||||
<label for="status" style="width:auto;">Status:</label>
|
||||
<select id="status" name="Status">
|
||||
<option value="">(alle)</option>
|
||||
<option value="O" selected="@(Model.Status == "O")">open</option>
|
||||
<option value="B" selected="@(Model.Status == "B")">beoordeeld</option>
|
||||
<option value="A" selected="@(Model.Status == "A")">afgerond</option>
|
||||
<option value="X" selected="@(Model.Status == "X")">ingetrokken</option>
|
||||
</select>
|
||||
|
||||
<input type="submit" value="Filteren" />
|
||||
</form>
|
||||
|
||||
<br />
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>BSN</th>
|
||||
<th>Naam</th>
|
||||
<th>Status</th>
|
||||
<th>Ontvangen</th>
|
||||
<th>Kanaal</th>
|
||||
<th>Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in Model.Aanvragen)
|
||||
{
|
||||
<tr class="@(a.Migrated ? "migrated" : "")">
|
||||
<td>@a.Id</td>
|
||||
<td>@a.Bsn</td>
|
||||
<td>@a.Naam @a.Voorl</td>
|
||||
<td>@Legacy.Web.Pages.IndexModel.StatusLabel(a.StatCd)</td>
|
||||
<td>@a.DatOntv.ToString("dd-MM-yyyy")</td>
|
||||
<td>@(a.CorrKanaal == "E" ? "e-mail" : "post")</td>
|
||||
<td>
|
||||
@if (a.Migrated)
|
||||
{
|
||||
<a href="/worklist/legacy/@a.Id">beheerd in nieuw portaal</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/legacy/aanvraag/@a.Id/beoordeling">Beoordelen</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Legacy.Web.Pages;
|
||||
|
||||
public class IndexModel(LegacyApiClient client) : PageModel
|
||||
{
|
||||
public List<AanvraagDto> Aanvragen { get; set; } = [];
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? Zoek { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
Aanvragen = await client.GetAllAsync(Zoek, Status);
|
||||
}
|
||||
|
||||
public static string StatusLabel(string statCd) => statCd switch
|
||||
{
|
||||
"O" => "open",
|
||||
"B" => "beoordeeld",
|
||||
"A" => "afgerond",
|
||||
"X" => "ingetrokken",
|
||||
_ => statCd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Diplomawaardering - Legacy systeem</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #d4d0c8;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
padding: 0 0 24px 0;
|
||||
}
|
||||
h1, h2 {
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
}
|
||||
.banner {
|
||||
background-color: #000080;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
font-size: 1.3em;
|
||||
border-bottom: 2px solid #000000;
|
||||
}
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
table, th, td {
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
th, td {
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background-color: #c0c0c0;
|
||||
}
|
||||
tr.migrated {
|
||||
color: #808080;
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
a {
|
||||
color: #0000ee;
|
||||
}
|
||||
button, input[type=submit] {
|
||||
background-color: #c0c0c0;
|
||||
border: 2px outset #808080;
|
||||
padding: 4px 14px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
}
|
||||
button:active, input[type=submit]:active {
|
||||
border-style: inset;
|
||||
}
|
||||
fieldset {
|
||||
border: 1px solid #000000;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 140px;
|
||||
font-weight: bold;
|
||||
}
|
||||
input[type=text], select, textarea {
|
||||
border: 1px solid #000000;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.melding {
|
||||
border: 1px solid #000000;
|
||||
background-color: #ffffcc;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="banner">Diplomawaardering — Aanvraagregistratie (Legacy systeem)</div>
|
||||
<div class="content">
|
||||
@RenderBody()
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
@namespace Legacy.Web.Pages
|
||||
@using Legacy.Web
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Legacy.Web;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
|
||||
var legacyApiBaseUrl = builder.Configuration["Services:LegacyApi:BaseUrl"]
|
||||
?? "http://localhost:8081";
|
||||
|
||||
builder.Services.AddHttpClient<LegacyApiClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(legacyApiBaseUrl);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapRazorPages();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Behandel portaal (placeholder)</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Behandel portaal — werkvoorraad (placeholder, session 2 replaces this with Angular)</h1>
|
||||
<p>
|
||||
<label>Bucket: <select id="bucket"><option value="">Alles</option><option value="Open">Te beoordelen</option><option value="Beoordeeld">Beoordeeld</option><option value="Ingetrokken">Ingetrokken</option></select></label>
|
||||
<label>Origin: <select id="origin"><option value="">alles</option><option value="Legacy">legacy</option><option value="Owned">nieuw proces</option></select></label>
|
||||
<label>Zoek: <input id="search" type="text"></label>
|
||||
<button onclick="loadWorklist()">Ververs</button>
|
||||
</p>
|
||||
<table border="1" cellpadding="4">
|
||||
<thead>
|
||||
<tr><th>Origin</th><th>Referentie</th><th>Naam</th><th>BSN</th><th>Ontvangen</th><th>Uitkomst</th><th>Processtatus</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
|
||||
<h2>Detail</h2>
|
||||
<pre id="detail">Kies een rij (klik erop) om details te laden.</pre>
|
||||
<div id="actions"></div>
|
||||
|
||||
<script>
|
||||
let currentKey = null;
|
||||
|
||||
async function loadWorklist() {
|
||||
const bucket = document.getElementById('bucket').value;
|
||||
const origin = document.getElementById('origin').value;
|
||||
const search = document.getElementById('search').value;
|
||||
const params = new URLSearchParams();
|
||||
if (bucket) params.set('bucket', bucket);
|
||||
if (origin) params.set('origin', origin);
|
||||
if (search) params.set('search', search);
|
||||
const res = await fetch('/api/worklist?' + params.toString());
|
||||
const data = await res.json();
|
||||
const rows = document.getElementById('rows');
|
||||
rows.innerHTML = '';
|
||||
for (const item of data.items) {
|
||||
const tr = document.createElement('tr');
|
||||
const key = item.origin === 'Legacy' ? `legacy/${item.legacyAanvraagId}` : `owned/${item.registrationApplicationId}`;
|
||||
tr.innerHTML = `<td>${item.origin}</td><td>${item.legacyAanvraagId ?? item.registrationApplicationId}</td>` +
|
||||
`<td>${item.surname} ${item.initials}</td><td>${item.bsn}</td><td>${item.receivedOn}</td>` +
|
||||
`<td>${item.assessmentOutcome ?? ''}</td><td>${item.processStatus ?? 'n.v.t.'}</td>`;
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.onclick = () => loadDetail(key);
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(key) {
|
||||
currentKey = key;
|
||||
const res = await fetch(`/api/worklist/${key}`);
|
||||
if (!res.ok) {
|
||||
document.getElementById('detail').textContent = `Fout: ${res.status}`;
|
||||
document.getElementById('actions').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const detail = await res.json();
|
||||
document.getElementById('detail').textContent = JSON.stringify(detail, null, 2);
|
||||
renderActions(detail);
|
||||
}
|
||||
|
||||
function renderActions(detail) {
|
||||
const el = document.getElementById('actions');
|
||||
el.innerHTML = '';
|
||||
|
||||
const a = detail.actions;
|
||||
|
||||
addButton(el, `Gegevens wijzigen (${a.editApplicantDetails.mode})`, async () => {
|
||||
const surname = prompt('Surname', detail.surname);
|
||||
if (surname === null) return;
|
||||
const initials = prompt('Initials', detail.initials);
|
||||
const street = prompt('Street (leeg = geen adres)', detail.address?.street ?? '');
|
||||
const number = street ? prompt('Number', detail.address?.number ?? '') : null;
|
||||
const postalCode = street ? prompt('Postal code', detail.address?.postalCode ?? '') : null;
|
||||
const city = street ? prompt('City', detail.address?.city ?? '') : null;
|
||||
const email = prompt('Email', detail.email ?? '');
|
||||
const phone = prompt('Phone', detail.phone ?? '');
|
||||
const preferredChannel = prompt('Preferred channel (Post/Email)', detail.preferredChannel);
|
||||
const body = {
|
||||
surname, initials,
|
||||
address: street ? { street, number, postalCode, city } : null,
|
||||
email, phone, preferredChannel,
|
||||
};
|
||||
const res = await fetch(a.editApplicantDetails.href, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
alert(`${res.status}: ${text}`);
|
||||
loadDetail(currentKey);
|
||||
});
|
||||
|
||||
if (a.recordAssessment.mode === 'redirect') {
|
||||
addLink(el, 'Beoordeling vastleggen (verlaat portaal — legacy valideert)', a.recordAssessment.href);
|
||||
} else {
|
||||
addButton(el, 'Beoordeling vastleggen', async () => {
|
||||
const verified = prompt('Verified items (comma separated: document,land,datum)', 'document,land,datum');
|
||||
const exceptionReason = verified ? null : prompt('Exception reason (verplicht als geen items geverifieerd)');
|
||||
const outcome = prompt('Outcome (Approved/Rejected)', 'Approved');
|
||||
const rejectionCategory = outcome === 'Rejected' ? prompt('Rejection category (onvolledig/niet erkend/niet bevoegd/anders)') : null;
|
||||
const motivation = prompt('Motivation (min 20 chars, 50 if "anders")');
|
||||
const body = {
|
||||
verifiedItems: verified ? verified.split(',').map(s => s.trim()) : [],
|
||||
exceptionReason, outcome, rejectionCategory, motivation,
|
||||
};
|
||||
const res = await fetch(a.recordAssessment.href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
alert(`${res.status}: ${text}`);
|
||||
loadDetail(currentKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (a.takeOwnership) {
|
||||
addButton(el, 'Overnemen in nieuw systeem', async () => {
|
||||
if (!confirm('Dit maakt het nieuwe systeem eigenaar van deze zaak. Doorgaan?')) return;
|
||||
const res = await fetch(a.takeOwnership.href, { method: 'POST' });
|
||||
const text = await res.text();
|
||||
alert(`${res.status}: ${text}`);
|
||||
loadWorklist();
|
||||
if (res.ok) {
|
||||
const body = JSON.parse(text);
|
||||
loadDetail(`owned/${body.registrationApplicationId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (a.releaseOwnership) {
|
||||
addButton(el, 'Overname terugdraaien', async () => {
|
||||
const res = await fetch(a.releaseOwnership.href, { method: 'DELETE' });
|
||||
const text = await res.text();
|
||||
alert(`${res.status}: ${text}`);
|
||||
loadWorklist();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addButton(container, label, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.onclick = onClick;
|
||||
container.appendChild(btn);
|
||||
container.appendChild(document.createElement('br'));
|
||||
}
|
||||
|
||||
function addLink(container, label, href) {
|
||||
const a = document.createElement('a');
|
||||
a.textContent = label;
|
||||
a.href = href;
|
||||
container.appendChild(a);
|
||||
container.appendChild(document.createElement('br'));
|
||||
}
|
||||
|
||||
loadWorklist();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY src/New.Domain/New.Domain.csproj src/New.Domain/
|
||||
COPY src/New.Application/New.Application.csproj src/New.Application/
|
||||
COPY src/New.Infrastructure.Persistence/New.Infrastructure.Persistence.csproj src/New.Infrastructure.Persistence/
|
||||
COPY src/New.Infrastructure.Legacy/New.Infrastructure.Legacy.csproj src/New.Infrastructure.Legacy/
|
||||
COPY src/New.Infrastructure.CaseFramework/New.Infrastructure.CaseFramework.csproj src/New.Infrastructure.CaseFramework/
|
||||
COPY src/New.Api/New.Api.csproj src/New.Api/
|
||||
|
||||
COPY src/ src/
|
||||
# restore+publish combined in one RUN/layer: podman/buildah has a known issue
|
||||
# where the NuGet global-packages cache uses hardlinks that break when a
|
||||
# restore layer and a later --no-restore publish layer are committed
|
||||
# separately, surfacing as a false "package not found" error.
|
||||
RUN dotnet restore src/New.Api/New.Api.csproj && \
|
||||
dotnet publish src/New.Api/New.Api.csproj -c Release -o /app --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "New.Api.dll"]
|
||||
@@ -0,0 +1,54 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the presentation-only `actions`/`seams` blocks on top of a
|
||||
/// CaseDetail read model. This is deliberately New.Api's job, not the
|
||||
/// resolver's or either source's - it's about which endpoints exist, which
|
||||
/// is an API-shape concern, not a data-source concern.
|
||||
/// </summary>
|
||||
internal static class CaseDetailResponseFactory
|
||||
{
|
||||
public static CaseDetailResponse From(CaseDetail detail)
|
||||
{
|
||||
var actions = detail.Origin == WorklistOrigin.Legacy
|
||||
? BuildLegacyActions(detail.LegacyAanvraagId!.Value)
|
||||
: BuildOwnedActions(detail.RegistrationApplicationId!.Value);
|
||||
|
||||
var seams = detail.Origin == WorklistOrigin.Legacy
|
||||
? new Dictionary<string, string?> { ["aanvrager"] = "legacy-backend", ["procestijdlijn"] = null }
|
||||
: new Dictionary<string, string?> { ["aanvrager"] = "owned", ["procestijdlijn"] = "case-framework-timeline" };
|
||||
|
||||
return new CaseDetailResponse(
|
||||
detail.Origin.ToString(),
|
||||
detail.LegacyAanvraagId,
|
||||
detail.RegistrationApplicationId,
|
||||
detail.Surname,
|
||||
detail.Initials,
|
||||
detail.Bsn,
|
||||
AddressResponse.From(detail.Address),
|
||||
detail.Email,
|
||||
detail.Phone,
|
||||
detail.PreferredChannel,
|
||||
detail.DiplomaCode,
|
||||
detail.DiplomaCountryOfIssue,
|
||||
detail.DiplomaIssuedOn,
|
||||
detail.ReceivedOn,
|
||||
AssessmentResponse.From(detail.Assessment),
|
||||
detail.ProcessStatus,
|
||||
detail.LastModifiedAt,
|
||||
actions,
|
||||
seams);
|
||||
}
|
||||
|
||||
private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new(
|
||||
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
|
||||
RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"),
|
||||
TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"));
|
||||
|
||||
private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new(
|
||||
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
|
||||
RecordAssessment: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/assessment"),
|
||||
ReleaseOwnership: new ActionLink("transition", $"/api/worklist/owned/{registrationApplicationId}/ownership"));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using New.Application.WriteThrough;
|
||||
using New.Application.Worklist;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Api.Contracts;
|
||||
|
||||
public sealed record AddressRequest(string Street, string Number, string PostalCode, string City)
|
||||
{
|
||||
public AddressData ToData() => new(Street, Number, PostalCode, City);
|
||||
}
|
||||
|
||||
public sealed record ApplicantDetailsRequest(
|
||||
string Surname,
|
||||
string Initials,
|
||||
AddressRequest? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel)
|
||||
{
|
||||
public ApplicantDetailsCommand ToCommand() => new(Surname, Initials, Address?.ToData(), Email, Phone, PreferredChannel);
|
||||
}
|
||||
|
||||
public sealed record RecordAssessmentRequest(
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
string Outcome,
|
||||
string? RejectionCategory,
|
||||
string Motivation);
|
||||
|
||||
public sealed record FieldErrorResponse(string Field, string Message, string? Detail = null)
|
||||
{
|
||||
public static FieldErrorResponse From(PortalFieldError error) => new(error.Field, error.Message, error.Detail);
|
||||
}
|
||||
|
||||
public sealed record ErrorsResponse(IReadOnlyList<FieldErrorResponse> Errors)
|
||||
{
|
||||
public static ErrorsResponse From(IReadOnlyList<PortalFieldError> errors) =>
|
||||
new(errors.Select(FieldErrorResponse.From).ToList());
|
||||
}
|
||||
|
||||
public sealed record InvariantViolationResponse(string Invariant, string Message);
|
||||
|
||||
public sealed record MessageResponse(string Message);
|
||||
|
||||
public sealed record RecordAssessmentResponse(bool ClosurePending);
|
||||
@@ -0,0 +1,83 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Api.Contracts;
|
||||
|
||||
public sealed record WorklistItemResponse(
|
||||
string Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
DateOnly ReceivedOn,
|
||||
string Bucket,
|
||||
string? AssessmentOutcome,
|
||||
string? ProcessStatus)
|
||||
{
|
||||
public static WorklistItemResponse From(WorklistItem item) => new(
|
||||
item.Origin.ToString(),
|
||||
item.LegacyAanvraagId,
|
||||
item.RegistrationApplicationId,
|
||||
item.Surname,
|
||||
item.Initials,
|
||||
item.Bsn,
|
||||
item.ReceivedOn,
|
||||
item.Bucket,
|
||||
item.AssessmentOutcome,
|
||||
item.ProcessStatus);
|
||||
}
|
||||
|
||||
public sealed record WorklistPageResponse(
|
||||
IReadOnlyList<WorklistItemResponse> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalCount);
|
||||
|
||||
public sealed record ActionLink(string Mode, string Href);
|
||||
|
||||
public sealed record CaseDetailActions(
|
||||
ActionLink EditApplicantDetails,
|
||||
ActionLink RecordAssessment,
|
||||
ActionLink? TakeOwnership = null,
|
||||
ActionLink? ReleaseOwnership = null);
|
||||
|
||||
public sealed record AddressResponse(string Street, string Number, string PostalCode, string City)
|
||||
{
|
||||
public static AddressResponse? From(AddressData? data) =>
|
||||
data is null ? null : new AddressResponse(data.Street, data.Number, data.PostalCode, data.City);
|
||||
}
|
||||
|
||||
public sealed record AssessmentResponse(
|
||||
string Outcome,
|
||||
string Motivation,
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
string? RejectionCategory,
|
||||
DateOnly DecidedOn)
|
||||
{
|
||||
public static AssessmentResponse? From(AssessmentData? data) =>
|
||||
data is null
|
||||
? null
|
||||
: new AssessmentResponse(data.Outcome, data.Motivation, data.VerifiedItems, data.ExceptionReason, data.RejectionCategory, data.DecidedOn);
|
||||
}
|
||||
|
||||
public sealed record CaseDetailResponse(
|
||||
string Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
AddressResponse? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel,
|
||||
string DiplomaCode,
|
||||
string DiplomaCountryOfIssue,
|
||||
DateOnly DiplomaIssuedOn,
|
||||
DateOnly ReceivedOn,
|
||||
AssessmentResponse? Assessment,
|
||||
string? ProcessStatus,
|
||||
DateTimeOffset? LastModifiedAt,
|
||||
CaseDetailActions Actions,
|
||||
IReadOnlyDictionary<string, string?> Seams);
|
||||
@@ -0,0 +1,46 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Assessments;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class AssessmentEndpoints
|
||||
{
|
||||
public static void MapAssessmentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/worklist/owned/{registrationApplicationId:guid}/assessment", RecordAssessmentAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecordAssessmentAsync(
|
||||
Guid registrationApplicationId, RecordAssessmentRequest request, RecordOwnedAssessmentHandler handler, CancellationToken ct)
|
||||
{
|
||||
AssessmentOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = Enum.Parse<AssessmentOutcome>(request.Outcome, ignoreCase: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Results.UnprocessableEntity(new InvariantViolationResponse(
|
||||
"Assessment.UnrecognizedOutcome", $"'{request.Outcome}' is not a recognized outcome (expected 'Approved' or 'Rejected')."));
|
||||
}
|
||||
|
||||
var command = new RecordAssessmentCommand(request.VerifiedItems, request.ExceptionReason, outcome, request.RejectionCategory, request.Motivation);
|
||||
|
||||
// Re-validates everything server-side via the domain's own
|
||||
// RecordAssessment, regardless of what the client already checked.
|
||||
var result = await handler.HandleAsync(registrationApplicationId, command, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
// Spec allows either a 204 with a body, or 204 plus a follow-up
|
||||
// field - since an HTTP 204 cannot carry a body, we use 200 with
|
||||
// a small { closurePending } body to actually convey it.
|
||||
RecordAssessmentResultKind.Success => Results.Ok(new RecordAssessmentResponse(result.ClosurePending)),
|
||||
RecordAssessmentResultKind.NotFound => Results.NotFound(),
|
||||
RecordAssessmentResultKind.InvariantViolation => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Ports;
|
||||
using New.Application.WriteThrough;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class DetailsEndpoints
|
||||
{
|
||||
public static void MapDetailsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPut("/api/worklist/legacy/{aanvraagId:int}/details", UpdateLegacyDetailsAsync);
|
||||
app.MapPut("/api/worklist/owned/{registrationApplicationId:guid}/details", UpdateOwnedDetailsAsync);
|
||||
}
|
||||
|
||||
// Seam B: write-through. New.Api's own job here is limited to translating
|
||||
// the HTTP request into the command and the outcome into an HTTP
|
||||
// response - the actual translation to/from legacy's shape (and the "no
|
||||
// business rules" constraint) lives in New.Infrastructure.Legacy.
|
||||
private static async Task<IResult> UpdateLegacyDetailsAsync(
|
||||
int aanvraagId, ApplicantDetailsRequest request, ILegacyCaseGateway gateway, CancellationToken ct)
|
||||
{
|
||||
var outcome = await gateway.UpdateDetailsAsync(aanvraagId, request.ToCommand(), ct);
|
||||
|
||||
return outcome.Kind switch
|
||||
{
|
||||
WriteThroughOutcomeKind.Success => Results.NoContent(),
|
||||
WriteThroughOutcomeKind.NotFound => Results.NotFound(),
|
||||
WriteThroughOutcomeKind.Conflict => Results.Conflict(new MessageResponse("This aanvraag has already been migrated and can no longer be edited in legacy.")),
|
||||
WriteThroughOutcomeKind.ValidationFailed => Results.BadRequest(ErrorsResponse.From(outcome.Errors!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOwnedDetailsAsync(
|
||||
Guid registrationApplicationId, ApplicantDetailsRequest request, UpdateOwnedApplicantDetailsHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(registrationApplicationId, request.ToCommand(), ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
UpdateOwnedApplicantDetailsResultKind.Success => Results.NoContent(),
|
||||
UpdateOwnedApplicantDetailsResultKind.NotFound => Results.NotFound(),
|
||||
UpdateOwnedApplicantDetailsResultKind.InvariantViolation => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using New.Infrastructure.Legacy;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class DiagnosticsEndpoints
|
||||
{
|
||||
public static void MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/diagnostics/legacy-call-count", (LegacyCallCounter counter) =>
|
||||
Results.Ok(new { count = counter.Count }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Ownership;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class OwnershipEndpoints
|
||||
{
|
||||
public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync);
|
||||
app.MapDelete("/api/worklist/owned/{registrationApplicationId:guid}/ownership", ReleaseOwnershipAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TakeOwnershipAsync(int aanvraagId, TakeOwnershipHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(aanvraagId, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
TakeOwnershipResultKind.Success => Results.Created(
|
||||
$"/api/worklist/owned/{result.RegistrationApplicationId}",
|
||||
new { registrationApplicationId = result.RegistrationApplicationId }),
|
||||
TakeOwnershipResultKind.AlreadyOwned => Results.Conflict(new MessageResponse("This aanvraag has already been taken into ownership.")),
|
||||
TakeOwnershipResultKind.LegacyCaseNotFound => Results.NotFound(),
|
||||
TakeOwnershipResultKind.MappingFailed => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(registrationApplicationId, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
ReleaseOwnershipResultKind.Success => Results.NoContent(),
|
||||
ReleaseOwnershipResultKind.NotOwned => Results.NotFound(),
|
||||
ReleaseOwnershipResultKind.Conflict => Results.Conflict(new MessageResponse(result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using New.Api.Contracts;
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Api.Endpoints;
|
||||
|
||||
public static class WorklistEndpoints
|
||||
{
|
||||
private const int PageSize = 10;
|
||||
|
||||
public static void MapWorklistEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/worklist", GetWorklistAsync);
|
||||
app.MapGet("/api/worklist/legacy/{aanvraagId:int}", GetLegacyDetailAsync);
|
||||
app.MapGet("/api/worklist/owned/{registrationApplicationId:guid}", GetOwnedDetailAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWorklistAsync(
|
||||
string? bucket,
|
||||
string? origin,
|
||||
string? search,
|
||||
string? sort,
|
||||
int? page,
|
||||
IOwnedWorklistReader ownedReader,
|
||||
ILegacyWorklistReader legacyReader,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Fetch both sources fully and merge/sort/page in memory here - a
|
||||
// known, deliberate shortcut for this demo's seed volumes (12 legacy
|
||||
// + 5 owned rows). A production version would need keyset pagination
|
||||
// per source or a materialized index instead.
|
||||
var ownedItems = await ownedReader.ListAsync(ct);
|
||||
var legacyItems = await legacyReader.ListAsync(ct);
|
||||
|
||||
// A legacy row already taken into ownership is now represented by
|
||||
// its owned counterpart - excluded here so it doesn't show up twice.
|
||||
var merged = ownedItems.Concat(legacyItems.Where(i => !i.Migrated));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bucket))
|
||||
{
|
||||
merged = merged.Where(i => string.Equals(i.Bucket, bucket, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(origin) && Enum.TryParse<WorklistOrigin>(origin, ignoreCase: true, out var parsedOrigin))
|
||||
{
|
||||
merged = merged.Where(i => i.Origin == parsedOrigin);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
merged = merged.Where(i =>
|
||||
i.Surname.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Initials.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Bsn.Contains(search, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
merged = sort switch
|
||||
{
|
||||
"surname" => merged.OrderBy(i => i.Surname),
|
||||
"-surname" => merged.OrderByDescending(i => i.Surname),
|
||||
"receivedOn" => merged.OrderBy(i => i.ReceivedOn),
|
||||
_ => merged.OrderByDescending(i => i.ReceivedOn), // default: "-receivedOn"
|
||||
};
|
||||
|
||||
var all = merged.ToList();
|
||||
var pageNumber = page is > 0 ? page.Value : 1;
|
||||
var pageItems = all.Skip((pageNumber - 1) * PageSize).Take(PageSize).Select(WorklistItemResponse.From).ToList();
|
||||
|
||||
return Results.Ok(new WorklistPageResponse(pageItems, pageNumber, PageSize, all.Count));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLegacyDetailAsync(int aanvraagId, IApplicationSource resolver, CancellationToken ct)
|
||||
{
|
||||
var detail = await resolver.GetByLegacyIdAsync(aanvraagId, ct);
|
||||
return detail is null ? Results.NotFound() : Results.Ok(CaseDetailResponseFactory.From(detail));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOwnedDetailAsync(
|
||||
Guid registrationApplicationId,
|
||||
New.Infrastructure.Persistence.OwnedApplicationSource owned,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var detail = await owned.GetAsync(registrationApplicationId, ct);
|
||||
return detail is null ? Results.NotFound() : Results.Ok(CaseDetailResponseFactory.From(detail));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Api</RootNamespace>
|
||||
<UserSecretsId>new-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Composition root: the only project allowed to reference every New.* project. -->
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
<ProjectReference Include="..\New.Infrastructure.Persistence\New.Infrastructure.Persistence.csproj" />
|
||||
<ProjectReference Include="..\New.Infrastructure.Legacy\New.Infrastructure.Legacy.csproj" />
|
||||
<ProjectReference Include="..\New.Infrastructure.CaseFramework\New.Infrastructure.CaseFramework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Api.Endpoints;
|
||||
using New.Api.Resolution;
|
||||
using New.Api.Seeding;
|
||||
using New.Application.Assessments;
|
||||
using New.Application.Ownership;
|
||||
using New.Application.Ports;
|
||||
using New.Application.WriteThrough;
|
||||
using New.Infrastructure.CaseFramework;
|
||||
using New.Infrastructure.Legacy;
|
||||
using New.Infrastructure.Persistence;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddPersistenceInfrastructure(builder.Configuration);
|
||||
builder.Services.AddLegacyInfrastructure(builder.Configuration);
|
||||
builder.Services.AddCaseFrameworkInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// The only registration in the whole solution naming both "source" types -
|
||||
// see ApplicationSourceResolver's remarks (Architecture.Tests rule 7).
|
||||
builder.Services.AddScoped<IApplicationSource, ApplicationSourceResolver>();
|
||||
|
||||
builder.Services.AddScoped<TakeOwnershipHandler>();
|
||||
builder.Services.AddScoped<ReleaseOwnershipHandler>();
|
||||
builder.Services.AddScoped<RecordOwnedAssessmentHandler>();
|
||||
builder.Services.AddScoped<UpdateOwnedApplicantDetailsHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<NewDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await OwnedApplicationSeeder.SeedAsync(app.Services);
|
||||
|
||||
app.MapWorklistEndpoints();
|
||||
app.MapDetailsEndpoints();
|
||||
app.MapOwnershipEndpoints();
|
||||
app.MapAssessmentEndpoints();
|
||||
app.MapDiagnosticsEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,33 @@
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
using New.Infrastructure.Legacy;
|
||||
using New.Infrastructure.Persistence;
|
||||
|
||||
namespace New.Api.Resolution;
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY type in the whole solution that references both
|
||||
/// <see cref="OwnedApplicationSource"/> and <see cref="LegacyCaseSource"/> -
|
||||
/// Architecture.Tests rule 7 asserts exactly that. Every other type that
|
||||
/// needs case data reaches it through a port (IApplicationSource for
|
||||
/// by-id resolution, or IOwnedWorklistReader/ILegacyWorklistReader for the
|
||||
/// merged worklist listing - deliberately different types, see those
|
||||
/// interfaces' remarks) without ever knowing there are two sources at all.
|
||||
/// This is the seam-hiding point of the whole "strangler fig" design: a
|
||||
/// legacy aanvraagId keeps working transparently after adoption, because
|
||||
/// this resolver - and only this resolver - knows to check the ownership
|
||||
/// registry first and redirect to the owned copy when present.
|
||||
/// </summary>
|
||||
internal sealed class ApplicationSourceResolver(
|
||||
OwnedApplicationSource owned,
|
||||
LegacyCaseSource legacy,
|
||||
IOwnershipRegistry registry) : IApplicationSource
|
||||
{
|
||||
public async Task<CaseDetail?> GetByLegacyIdAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var ownedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
||||
return ownedId is null
|
||||
? await legacy.GetAsync(aanvraagId, ct) // seam A
|
||||
: await owned.GetAsync(ownedId.Value, ct); // owned
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
using New.Infrastructure.Persistence;
|
||||
|
||||
namespace New.Api.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent startup seeder for the 5 natively-owned applications
|
||||
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
|
||||
/// README click-through can reference them directly). REG-2026-0002 is
|
||||
/// seeded with an open case-framework task on purpose, so a later closure
|
||||
/// request against it demonstrates the §6 conflict (409, decision stands).
|
||||
/// </summary>
|
||||
internal static class OwnedApplicationSeeder
|
||||
{
|
||||
private const string CaseTypeCode = "RegistrationApplication";
|
||||
|
||||
private sealed record Seed(
|
||||
Guid Id,
|
||||
string ExternalReference,
|
||||
string Bsn,
|
||||
string Surname,
|
||||
string Initials,
|
||||
DateOnly ReceivedOn,
|
||||
string DiplomaCode,
|
||||
string DiplomaCountry,
|
||||
DateOnly DiplomaIssuedOn,
|
||||
bool OpenTask,
|
||||
AssessmentOutcome? Outcome,
|
||||
string? RejectionCategory);
|
||||
|
||||
private static readonly Seed[] Seeds =
|
||||
[
|
||||
new(new Guid("00000000-0000-0000-0000-000000000001"), "REG-2026-0001", "123456782", "de Groot", "A.",
|
||||
new DateOnly(2025, 9, 12), "MSC-INFO", "DE", new DateOnly(2024, 7, 1), false, AssessmentOutcome.Approved, null),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000002"), "REG-2026-0002", "234567892", "Hendriks", "M.J.",
|
||||
new DateOnly(2025, 10, 3), "BSC-ENG", "BE", new DateOnly(2023, 6, 15), true, null, null),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000003"), "REG-2026-0003", "345678904", "Kuipers", "R.",
|
||||
new DateOnly(2025, 11, 20), "MSC-LAW", "FR", new DateOnly(2022, 3, 10), false, AssessmentOutcome.Rejected, "NietErkend"),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000004"), "REG-2026-0004", "456789017", "Postma", "S.E.",
|
||||
new DateOnly(2026, 1, 5), "BSC-MED", "ES", new DateOnly(2021, 9, 1), false, AssessmentOutcome.Approved, null),
|
||||
new(new Guid("00000000-0000-0000-0000-000000000005"), "REG-2026-0005", "567890120", "van Dijk", "T.",
|
||||
new DateOnly(2026, 2, 14), "MSC-ARCH", "IT", new DateOnly(2020, 5, 20), false, null, null),
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(IServiceProvider services, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<NewDbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("OwnedApplicationSeeder");
|
||||
|
||||
if (await db.RegistrationApplications.AnyAsync(ct))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IRegistrationApplicationRepository>();
|
||||
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
var caseFramework = scope.ServiceProvider.GetRequiredService<ICaseFrameworkGateway>();
|
||||
|
||||
foreach (var seed in Seeds)
|
||||
{
|
||||
var application = RegistrationApplication.Create(
|
||||
seed.Id,
|
||||
new Bsn(seed.Bsn),
|
||||
new PersonName(seed.Surname, seed.Initials),
|
||||
correspondenceAddress: null,
|
||||
new ContactDetails(email: null, phone: null, CorrespondenceChannel.Post),
|
||||
new DiplomaEvidence(seed.DiplomaCode, seed.DiplomaCountry, seed.DiplomaIssuedOn),
|
||||
seed.ReceivedOn);
|
||||
|
||||
if (seed.Outcome is { } outcome)
|
||||
{
|
||||
var motivation = outcome == AssessmentOutcome.Approved
|
||||
? "Alle overgelegde bewijsstukken zijn gecontroleerd en in orde bevonden."
|
||||
: "Het overgelegde diploma wordt niet erkend door de bevoegde autoriteit.";
|
||||
|
||||
application.RecordAssessment(
|
||||
outcome,
|
||||
motivation,
|
||||
verifiedItems: ["document", "land", "datum"],
|
||||
exceptionReason: null,
|
||||
seed.RejectionCategory,
|
||||
seed.ReceivedOn.AddDays(14));
|
||||
}
|
||||
|
||||
// case-framework may still be starting up when this runs -
|
||||
// depends_on only guarantees the container process started, not
|
||||
// that it's ready to accept connections. Retry with backoff
|
||||
// rather than crashing the whole API on a slow neighbor.
|
||||
var created = await CreateCaseWithRetryAsync(caseFramework, seed.ExternalReference, seed.Surname, seed.Initials, logger, ct);
|
||||
|
||||
application.AttachCaseReference(new CaseReference(created.CaseId, seed.ExternalReference, created.ProcessStatus));
|
||||
|
||||
if (seed.OpenTask)
|
||||
{
|
||||
await caseFramework.CreateTaskAsync(created.CaseId, "ADMIN-CLOSURE", "Administratieve afronding", ct);
|
||||
}
|
||||
|
||||
await repository.AddAsync(application, ct);
|
||||
}
|
||||
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task<CaseCreated> CreateCaseWithRetryAsync(
|
||||
ICaseFrameworkGateway gateway, string externalReference, string surname, string initials, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
const int maxAttempts = 10;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.CreateCaseAsync(CaseTypeCode, externalReference, [$"{surname} {initials}"], ct);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
logger.LogWarning(ex, "case-framework not ready yet while seeding {ExternalReference} (attempt {Attempt}/{MaxAttempts}), retrying...",
|
||||
externalReference, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Application.Assessments;
|
||||
|
||||
public sealed record RecordAssessmentCommand(
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
AssessmentOutcome Outcome,
|
||||
string? RejectionCategory,
|
||||
string Motivation);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace New.Application.Assessments;
|
||||
|
||||
public enum RecordAssessmentResultKind
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
InvariantViolation,
|
||||
}
|
||||
|
||||
public sealed record RecordAssessmentResult(
|
||||
RecordAssessmentResultKind Kind,
|
||||
bool ClosurePending = false,
|
||||
string? Invariant = null,
|
||||
string? Message = null)
|
||||
{
|
||||
public static RecordAssessmentResult Success(bool closurePending) =>
|
||||
new(RecordAssessmentResultKind.Success, ClosurePending: closurePending);
|
||||
|
||||
public static readonly RecordAssessmentResult NotFound = new(RecordAssessmentResultKind.NotFound);
|
||||
|
||||
public static RecordAssessmentResult InvariantViolation(string invariant, string message) =>
|
||||
new(RecordAssessmentResultKind.InvariantViolation, Invariant: invariant, Message: message);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Application.Assessments;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates POST /api/worklist/owned/{id}/assessment. Re-validates
|
||||
/// everything server-side via the domain's own RecordAssessment method,
|
||||
/// regardless of what the client already checked.
|
||||
/// </summary>
|
||||
public sealed class RecordOwnedAssessmentHandler(
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork,
|
||||
IOwnershipRegistry registry,
|
||||
ICaseFrameworkGateway caseFrameworkGateway,
|
||||
TimeProvider clock)
|
||||
{
|
||||
public async Task<RecordAssessmentResult> HandleAsync(Guid registrationApplicationId, RecordAssessmentCommand command, CancellationToken ct)
|
||||
{
|
||||
var application = await repository.GetAsync(registrationApplicationId, ct);
|
||||
if (application is null)
|
||||
{
|
||||
return RecordAssessmentResult.NotFound;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
application.RecordAssessment(
|
||||
command.Outcome,
|
||||
command.Motivation,
|
||||
command.VerifiedItems,
|
||||
command.ExceptionReason,
|
||||
command.RejectionCategory,
|
||||
DateOnly.FromDateTime(clock.GetUtcNow().Date));
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return RecordAssessmentResult.InvariantViolation(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
// One transaction for the assessment write and the domain_writes_since
|
||||
// bump that gates ownership release.
|
||||
await registry.IncrementDomainWritesAsync(registrationApplicationId, ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
var closurePending = false;
|
||||
if (application.Case is not null)
|
||||
{
|
||||
// A 409 (open task) here is expected and fine - the assessment
|
||||
// already succeeded above and is NOT rolled back for it; we just
|
||||
// report that closure is pending.
|
||||
var closed = await caseFrameworkGateway.RequestClosureAsync(application.Case.FrameworkCaseId, ct);
|
||||
closurePending = !closed;
|
||||
}
|
||||
|
||||
return RecordAssessmentResult.Success(closurePending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Application</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Logging abstractions only - no concrete logging provider, no infra. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Application layer: ports (interfaces) and orchestration only. No EF Core,
|
||||
no HttpClient, no infrastructure project references - handlers here talk
|
||||
to the outside world exclusively through the ports defined in this project.
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
using New.Application.Ports;
|
||||
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates "release ownership" (DELETE /api/worklist/owned/{id}/ownership).
|
||||
/// The framework case is deliberately left as-is on release - a logged orphan,
|
||||
/// not cleaned up, per the spec.
|
||||
/// </summary>
|
||||
public sealed class ReleaseOwnershipHandler(
|
||||
IOwnershipRegistry registry,
|
||||
ILegacyCaseGateway legacyGateway,
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
public async Task<ReleaseOwnershipResult> HandleAsync(Guid registrationApplicationId, CancellationToken ct)
|
||||
{
|
||||
var record = await registry.GetAsync(registrationApplicationId, ct);
|
||||
if (record is null)
|
||||
{
|
||||
return ReleaseOwnershipResult.NotOwned;
|
||||
}
|
||||
|
||||
// Releasing would discard un-synced edits made through the owned path
|
||||
// since adoption - refuse rather than silently lose them.
|
||||
if (record.DomainWritesSince > 0)
|
||||
{
|
||||
return ReleaseOwnershipResult.Conflict(
|
||||
"Releasing ownership would discard un-synced edits made since this case was taken into ownership.");
|
||||
}
|
||||
|
||||
await legacyGateway.SetMigratedFlagAsync(record.LegacyAanvraagId, migrated: false, ct);
|
||||
|
||||
var application = await repository.GetAsync(registrationApplicationId, ct);
|
||||
if (application is not null)
|
||||
{
|
||||
await repository.RemoveAsync(application, ct);
|
||||
}
|
||||
|
||||
await registry.RemoveAsync(registrationApplicationId, ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
return ReleaseOwnershipResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
public enum ReleaseOwnershipResultKind
|
||||
{
|
||||
Success,
|
||||
NotOwned,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
public sealed record ReleaseOwnershipResult(ReleaseOwnershipResultKind Kind, string? Message = null)
|
||||
{
|
||||
public static readonly ReleaseOwnershipResult Success = new(ReleaseOwnershipResultKind.Success);
|
||||
public static readonly ReleaseOwnershipResult NotOwned = new(ReleaseOwnershipResultKind.NotOwned);
|
||||
|
||||
public static ReleaseOwnershipResult Conflict(string message) =>
|
||||
new(ReleaseOwnershipResultKind.Conflict, message);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates "take ownership" of a legacy case (POST
|
||||
/// /api/worklist/legacy/{aanvraagId}/take-ownership). References only ports -
|
||||
/// see Architecture.Tests rule 8 - never any New.Infrastructure.* concrete
|
||||
/// type, so this handler can be unit-tested (if this demo had a test suite
|
||||
/// for it) against fakes with zero HTTP/DB involved.
|
||||
///
|
||||
/// The step order below is load-bearing, not incidental - see the comment on
|
||||
/// each step for why it can't be reordered.
|
||||
/// </summary>
|
||||
public sealed class TakeOwnershipHandler(
|
||||
IOwnershipRegistry registry,
|
||||
ILegacyCaseGateway legacyGateway,
|
||||
ICaseFrameworkGateway caseFrameworkGateway,
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork,
|
||||
TimeProvider clock,
|
||||
ILogger<TakeOwnershipHandler> logger)
|
||||
{
|
||||
private const string CaseTypeCode = "RegistrationApplication";
|
||||
|
||||
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
// Step 1: guard against double adoption. Checked first and cheaply,
|
||||
// before touching legacy or case-framework at all.
|
||||
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
||||
if (existingOwnedId is not null)
|
||||
{
|
||||
return TakeOwnershipResult.AlreadyOwned;
|
||||
}
|
||||
|
||||
// Step 2: read the legacy case (seam A).
|
||||
// Step 3: map it to a RegistrationApplication. The mapper calls the
|
||||
// domain's normal validating constructors/factories, so any domain
|
||||
// exception here means the legacy data doesn't satisfy an invariant
|
||||
// the owned side requires. That must fail as a 422 naming the
|
||||
// failing invariant, and - critically - NOTHING is written anywhere:
|
||||
// no case-framework call, no persistence. FetchAndMapAsync lets the
|
||||
// domain exception surface as a thrown DomainInvariantViolationException,
|
||||
// which we catch here and translate, rather than swallowing it inside
|
||||
// the gateway - that keeps "nothing written on failure" trivially true,
|
||||
// since we simply haven't called anything else yet.
|
||||
LegacyFetchAndMapResult fetchResult;
|
||||
try
|
||||
{
|
||||
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
|
||||
{
|
||||
return TakeOwnershipResult.LegacyCaseNotFound;
|
||||
}
|
||||
|
||||
var application = fetchResult.Application;
|
||||
|
||||
// Step 4: THEN create the case-framework case - done before the local
|
||||
// transaction because it's an external system with no distributed
|
||||
// transaction available. A failure after this step leaves an
|
||||
// orphaned framework case (its externalReference matches no
|
||||
// aggregate) - detectable by a reconciliation query, not rolled back
|
||||
// here since case-framework has no compensating "delete case" seam.
|
||||
var created = await caseFrameworkGateway.CreateCaseAsync(
|
||||
CaseTypeCode,
|
||||
externalReference: application.RegistrationApplicationId.ToString(),
|
||||
participants: [$"{application.Applicant.Surname} {application.Applicant.Initials}"],
|
||||
ct);
|
||||
|
||||
application.AttachCaseReference(new CaseReference(
|
||||
created.CaseId,
|
||||
application.RegistrationApplicationId.ToString(),
|
||||
created.ProcessStatus));
|
||||
|
||||
// Step 5: persist the aggregate AND the legacy_ownership row in ONE
|
||||
// local transaction - both ports below are backed by the same scoped
|
||||
// DbContext, so the single SaveChangesAsync call is atomic across them.
|
||||
await repository.AddAsync(application, ct);
|
||||
await registry.RecordAsync(aanvraagId, application.RegistrationApplicationId, clock.GetUtcNow(), ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
// Step 6: LAST, flip legacy's migratie-vlag. A failure here leaves the
|
||||
// case owned locally but still writable in legacy (split-brain) -
|
||||
// detectable by a reconciliation query comparing legacy_ownership
|
||||
// against legacy's own migrated flags. We deliberately do not roll
|
||||
// back steps 4/5 if this fails: the aggregate is already the
|
||||
// system-of-record locally, and undoing that would be worse than a
|
||||
// detectable, reconcilable split-brain window.
|
||||
try
|
||||
{
|
||||
await legacyGateway.SetMigratedFlagAsync(aanvraagId, migrated: true, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to set legacy migratie-vlag for aanvraag {AanvraagId} after taking ownership as {RegistrationApplicationId}. " +
|
||||
"This is a split-brain condition: reconcile via legacy_ownership vs legacy's migrated flags.",
|
||||
aanvraagId,
|
||||
application.RegistrationApplicationId);
|
||||
}
|
||||
|
||||
return TakeOwnershipResult.Success(application.RegistrationApplicationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace New.Application.Ownership;
|
||||
|
||||
public enum TakeOwnershipResultKind
|
||||
{
|
||||
Success,
|
||||
AlreadyOwned,
|
||||
LegacyCaseNotFound,
|
||||
MappingFailed,
|
||||
}
|
||||
|
||||
public sealed record TakeOwnershipResult(
|
||||
TakeOwnershipResultKind Kind,
|
||||
Guid? RegistrationApplicationId = null,
|
||||
string? Invariant = null,
|
||||
string? Message = null)
|
||||
{
|
||||
public static TakeOwnershipResult Success(Guid registrationApplicationId) =>
|
||||
new(TakeOwnershipResultKind.Success, RegistrationApplicationId: registrationApplicationId);
|
||||
|
||||
public static readonly TakeOwnershipResult AlreadyOwned = new(TakeOwnershipResultKind.AlreadyOwned);
|
||||
public static readonly TakeOwnershipResult LegacyCaseNotFound = new(TakeOwnershipResultKind.LegacyCaseNotFound);
|
||||
|
||||
public static TakeOwnershipResult MappingFailed(string invariant, string message) =>
|
||||
new(TakeOwnershipResultKind.MappingFailed, Invariant: invariant, Message: message);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a case by its legacy id transparently, regardless of whether it
|
||||
/// has been taken into ownership. Implemented by the (single) source
|
||||
/// resolver - see the composition root for why that type is the only one
|
||||
/// allowed to know both sources exist (Architecture.Tests rule 7).
|
||||
/// </summary>
|
||||
public interface IApplicationSource
|
||||
{
|
||||
Task<CaseDetail?> GetByLegacyIdAsync(int aanvraagId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace New.Application.Ports;
|
||||
|
||||
public sealed record CaseCreated(Guid CaseId, string? ProcessStatus);
|
||||
|
||||
public sealed record TaskCreated(Guid TaskId, bool Open);
|
||||
|
||||
/// <summary>Seam D: the case-framework client port (New.Infrastructure.CaseFramework implements this).</summary>
|
||||
public interface ICaseFrameworkGateway
|
||||
{
|
||||
Task<CaseCreated> CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList<string> participants, CancellationToken ct);
|
||||
|
||||
Task<string?> GetProcessStatusAsync(Guid caseId, CancellationToken ct);
|
||||
|
||||
Task<TaskCreated> CreateTaskAsync(Guid caseId, string code, string description, CancellationToken ct);
|
||||
|
||||
Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// POST .../closure-request. Returns true if the case closed, false if
|
||||
/// the framework returned 409 (an open task) - which is an expected,
|
||||
/// non-exceptional outcome for callers (e.g. the owned assessment flow
|
||||
/// treats it as "closure pending", not a failure).
|
||||
/// </summary>
|
||||
Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using New.Application.WriteThrough;
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
public enum LegacyFetchStatus
|
||||
{
|
||||
Found,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
public sealed record LegacyFetchAndMapResult(LegacyFetchStatus Status, RegistrationApplication? Application);
|
||||
|
||||
/// <summary>
|
||||
/// The legacy-facing operations needed by the take-ownership flow, the
|
||||
/// write-through edit seam, and ownership release - as opposed to
|
||||
/// <c>LegacyCaseSource</c> (seam A read, used only by the source resolver).
|
||||
/// Kept as a separate port/type from that read seam deliberately (see
|
||||
/// Architecture.Tests rule 7's remarks on the resolver's exclusivity).
|
||||
/// </summary>
|
||||
public interface ILegacyCaseGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the legacy case and maps it to a <see cref="RegistrationApplication"/>
|
||||
/// via the internal mapper. A domain exception during mapping propagates
|
||||
/// as-is (callers such as the take-ownership handler turn it into a 422) -
|
||||
/// this method itself never swallows mapping failures.
|
||||
/// </summary>
|
||||
Task<LegacyFetchAndMapResult> FetchAndMapAsync(int aanvraagId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Seam B: PUT .../gegevens. This is a pure translation - see
|
||||
/// LegacyDetailsWriteThroughTranslator for the "no business rules" comment.
|
||||
/// </summary>
|
||||
Task<WriteThroughOutcome> UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct);
|
||||
|
||||
/// <summary>PUT .../migratie-vlag. Used on take-ownership (true) and release-ownership (false).</summary>
|
||||
Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>Lists legacy applications (via seam A) for the merged worklist.</summary>
|
||||
public interface ILegacyWorklistReader
|
||||
{
|
||||
Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// Lists owned applications for the merged worklist. Deliberately a
|
||||
/// different port/type than whatever the source resolver uses to fetch a
|
||||
/// single owned case by id - see Architecture.Tests rule 7's remarks on the
|
||||
/// resolver being the only type that reaches into both sources.
|
||||
/// </summary>
|
||||
public interface IOwnedWorklistReader
|
||||
{
|
||||
Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>Read-model row of the `legacy_ownership` table.</summary>
|
||||
public sealed record OwnershipRecord(
|
||||
int LegacyAanvraagId,
|
||||
Guid RegistrationApplicationId,
|
||||
DateTimeOffset TakenOverAt,
|
||||
int DomainWritesSince);
|
||||
|
||||
/// <summary>
|
||||
/// The `legacy_ownership` table - which legacy aanvraagen have been taken
|
||||
/// into ownership, and how many domain writes have happened since (which
|
||||
/// gates whether ownership can be released again).
|
||||
/// </summary>
|
||||
public interface IOwnershipRegistry
|
||||
{
|
||||
/// <summary>Null if <paramref name="legacyAanvraagId"/> has not been taken into ownership.</summary>
|
||||
Task<Guid?> LookupOwnedIdAsync(int legacyAanvraagId, CancellationToken ct);
|
||||
|
||||
Task<OwnershipRecord?> GetAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages a new ownership row (flushed by <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task RecordAsync(int legacyAanvraagId, Guid registrationApplicationId, DateTimeOffset takenOverAt, CancellationToken ct);
|
||||
|
||||
/// <summary>Increments `domain_writes_since` for a domain write against an adopted aggregate.</summary>
|
||||
Task IncrementDomainWritesAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages removal of the ownership row (flushed by <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task RemoveAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>Owned-side persistence port for the <see cref="RegistrationApplication"/> aggregate.</summary>
|
||||
public interface IRegistrationApplicationRepository
|
||||
{
|
||||
Task<RegistrationApplication?> GetAsync(Guid registrationApplicationId, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages a brand-new aggregate for insertion (flushed on the next <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task AddAsync(RegistrationApplication application, CancellationToken ct);
|
||||
|
||||
/// <summary>Stages an aggregate for deletion (flushed on the next <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
|
||||
Task RemoveAsync(RegistrationApplication application, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace New.Application.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// Commits everything staged through <see cref="IRegistrationApplicationRepository"/>
|
||||
/// and <see cref="IOwnershipRegistry"/> in one local transaction. In the
|
||||
/// Persistence adapter both ports are backed by the same scoped DbContext, so
|
||||
/// a single SaveChangesAsync call is genuinely atomic across them.
|
||||
/// </summary>
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>
|
||||
/// Plain read-model carrier for an address - deliberately NOT the
|
||||
/// New.Domain.ValueObjects.Address value object. Read models cross into
|
||||
/// New.Api for JSON shaping and must stay decoupled from domain invariants
|
||||
/// (e.g. a query result can legitimately be assembled straight from a
|
||||
/// legacy/case-framework response before any domain validation happens).
|
||||
/// </summary>
|
||||
public sealed record AddressData(string Street, string Number, string PostalCode, string City);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>Read-model projection of a recorded assessment, for display purposes.</summary>
|
||||
public sealed record AssessmentData(
|
||||
string Outcome,
|
||||
string Motivation,
|
||||
IReadOnlyList<string> VerifiedItems,
|
||||
string? ExceptionReason,
|
||||
string? RejectionCategory,
|
||||
DateOnly DecidedOn);
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>
|
||||
/// Full case detail projection, shaped the same way regardless of which
|
||||
/// source it came from - New.Api layers the `actions`/`seams` blocks on top
|
||||
/// based on <see cref="Origin"/>.
|
||||
/// </summary>
|
||||
public sealed record CaseDetail(
|
||||
WorklistOrigin Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
AddressData? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel,
|
||||
string DiplomaCode,
|
||||
string DiplomaCountryOfIssue,
|
||||
DateOnly DiplomaIssuedOn,
|
||||
DateOnly ReceivedOn,
|
||||
AssessmentData? Assessment,
|
||||
string? ProcessStatus,
|
||||
Guid? CaseFrameworkCaseId,
|
||||
bool Migrated,
|
||||
/// <summary>
|
||||
/// UTC instant of the last legacy mutation, if known. Legacy's own
|
||||
/// `mutDat` is a local Europe/Amsterdam timestamp with no offset - the
|
||||
/// legacy mapper converts it explicitly via that time zone rather than
|
||||
/// assuming UTC (which would silently shift it by 1-2 hours depending on
|
||||
/// DST). Null for owned/native cases with no legacy mutation history.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastModifiedAt = null);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the merged worklist (GET /api/worklist). New.Api fetches these
|
||||
/// from both sources in full and merges/sorts/pages them in memory - a known
|
||||
/// shortcut for this demo's seed volumes (12 legacy + 5 owned rows); a
|
||||
/// production version would need keyset pagination per source or a
|
||||
/// materialized index instead.
|
||||
/// </summary>
|
||||
public sealed record WorklistItem(
|
||||
WorklistOrigin Origin,
|
||||
int? LegacyAanvraagId,
|
||||
Guid? RegistrationApplicationId,
|
||||
string Surname,
|
||||
string Initials,
|
||||
string Bsn,
|
||||
DateOnly ReceivedOn,
|
||||
string Bucket,
|
||||
string? AssessmentOutcome,
|
||||
string? ProcessStatus,
|
||||
DateTimeOffset? LastModifiedAt = null,
|
||||
/// <summary>
|
||||
/// True for a legacy row already taken into ownership. New.Api's worklist
|
||||
/// merge excludes such rows from the legacy list (the owned counterpart
|
||||
/// already represents them) - see the merge comment in New.Api.
|
||||
/// </summary>
|
||||
bool Migrated = false);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace New.Application.Worklist;
|
||||
|
||||
/// <summary>Which of the two sources a worklist item or case detail came from.</summary>
|
||||
public enum WorklistOrigin
|
||||
{
|
||||
Legacy,
|
||||
Owned,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
/// <summary>
|
||||
/// The 9-field "edit applicant details" command, shared verbatim by the
|
||||
/// legacy write-through seam (PUT /api/worklist/legacy/{id}/details) and the
|
||||
/// owned edit path (PUT /api/worklist/owned/{id}/details).
|
||||
/// </summary>
|
||||
public sealed record ApplicantDetailsCommand(
|
||||
string Surname,
|
||||
string Initials,
|
||||
AddressData? Address,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string PreferredChannel);
|
||||
@@ -0,0 +1,60 @@
|
||||
using New.Application.Ports;
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates PUT /api/worklist/owned/{id}/details - the owned-side
|
||||
/// counterpart to the legacy write-through seam. Unlike the write-through
|
||||
/// translator, this path re-validates through the domain's real value
|
||||
/// objects (there is no external "legacy is the sole authority" constraint
|
||||
/// here - this IS the authority once a case is owned).
|
||||
/// </summary>
|
||||
public sealed class UpdateOwnedApplicantDetailsHandler(
|
||||
IRegistrationApplicationRepository repository,
|
||||
IUnitOfWork unitOfWork,
|
||||
IOwnershipRegistry registry)
|
||||
{
|
||||
public async Task<UpdateOwnedApplicantDetailsResult> HandleAsync(
|
||||
Guid registrationApplicationId,
|
||||
ApplicantDetailsCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var application = await repository.GetAsync(registrationApplicationId, ct);
|
||||
if (application is null)
|
||||
{
|
||||
return UpdateOwnedApplicantDetailsResult.NotFound;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var applicant = new PersonName(command.Surname, command.Initials);
|
||||
var address = command.Address is { } a
|
||||
? new Address(a.Street, a.Number, a.PostalCode, a.City)
|
||||
: null;
|
||||
var channel = ParseChannel(command.PreferredChannel);
|
||||
var contactDetails = new ContactDetails(command.Email, command.Phone, channel);
|
||||
|
||||
application.UpdateApplicantDetails(applicant, address, contactDetails);
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return UpdateOwnedApplicantDetailsResult.InvariantViolation(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
await registry.IncrementDomainWritesAsync(registrationApplicationId, ct);
|
||||
await unitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
return UpdateOwnedApplicantDetailsResult.Success;
|
||||
}
|
||||
|
||||
private static CorrespondenceChannel ParseChannel(string preferredChannel) => preferredChannel switch
|
||||
{
|
||||
"Post" => CorrespondenceChannel.Post,
|
||||
"Email" => CorrespondenceChannel.Email,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"ContactDetails.UnrecognizedChannel",
|
||||
$"'{preferredChannel}' is not a recognized preferred channel (expected 'Post' or 'Email')."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
public enum UpdateOwnedApplicantDetailsResultKind
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
InvariantViolation,
|
||||
}
|
||||
|
||||
public sealed record UpdateOwnedApplicantDetailsResult(
|
||||
UpdateOwnedApplicantDetailsResultKind Kind,
|
||||
string? Invariant = null,
|
||||
string? Message = null)
|
||||
{
|
||||
public static readonly UpdateOwnedApplicantDetailsResult Success = new(UpdateOwnedApplicantDetailsResultKind.Success);
|
||||
public static readonly UpdateOwnedApplicantDetailsResult NotFound = new(UpdateOwnedApplicantDetailsResultKind.NotFound);
|
||||
|
||||
public static UpdateOwnedApplicantDetailsResult InvariantViolation(string invariant, string message) =>
|
||||
new(UpdateOwnedApplicantDetailsResultKind.InvariantViolation, invariant, message);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace New.Application.WriteThrough;
|
||||
|
||||
/// <summary>
|
||||
/// A single portal-shaped field error, after the write-through translator has
|
||||
/// mapped a legacy `veld`/`code`/`melding` triple. <see cref="Detail"/> only
|
||||
/// carries the raw legacy message, and only for codes the translator did not
|
||||
/// recognize - see the translator's class-level comment on why it must never
|
||||
/// invent business meaning for a code it doesn't know.
|
||||
/// </summary>
|
||||
public sealed record PortalFieldError(string Field, string Message, string? Detail = null);
|
||||
|
||||
public enum WriteThroughOutcomeKind
|
||||
{
|
||||
Success,
|
||||
ValidationFailed,
|
||||
Conflict,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
public sealed record WriteThroughOutcome(WriteThroughOutcomeKind Kind, IReadOnlyList<PortalFieldError>? Errors = null)
|
||||
{
|
||||
public static readonly WriteThroughOutcome Success = new(WriteThroughOutcomeKind.Success);
|
||||
public static readonly WriteThroughOutcome Conflict = new(WriteThroughOutcomeKind.Conflict);
|
||||
public static readonly WriteThroughOutcome NotFound = new(WriteThroughOutcomeKind.NotFound);
|
||||
|
||||
public static WriteThroughOutcome ValidationFailed(IReadOnlyList<PortalFieldError> errors) =>
|
||||
new(WriteThroughOutcomeKind.ValidationFailed, errors);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace New.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown whenever a domain invariant is violated - by native creation of a
|
||||
/// <see cref="RegistrationApplication"/>, or by the legacy mapper feeding data
|
||||
/// through the same validating constructors/factories during adoption.
|
||||
///
|
||||
/// <see cref="Invariant"/> is a short, stable, machine-friendly code (e.g.
|
||||
/// "Bsn.ElevenProof", "Assessment.MotivationTooShort") that callers such as
|
||||
/// New.Api can surface directly in a 422 response body without needing to
|
||||
/// parse the human-readable <see cref="Exception.Message"/>.
|
||||
/// </summary>
|
||||
public sealed class DomainInvariantViolationException : Exception
|
||||
{
|
||||
public string Invariant { get; }
|
||||
|
||||
public DomainInvariantViolationException(string invariant, string message)
|
||||
: base(message)
|
||||
{
|
||||
Invariant = invariant;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Domain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
No package or project references on purpose: New.Domain is the innermost
|
||||
layer of the DDD-flavored design in this solution. It must never depend
|
||||
on EF Core, HttpClient, ASP.NET Core, or DTOs from the legacy system or
|
||||
the case-framework - those are all infrastructure concerns kept out by
|
||||
the project-reference graph (see Architecture.Tests for the enforced rules).
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,186 @@
|
||||
using New.Domain.ValueObjects;
|
||||
|
||||
namespace New.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate root for a registration application ("aanvraag" in the legacy
|
||||
/// system). Has no base class, no `ICaseEntity` interface, and no property of
|
||||
/// a type from the case-framework or legacy DTOs - see Architecture.Tests for
|
||||
/// the enforced boundary. All invariants are enforced here, in the
|
||||
/// constructor/factory methods and the mutation methods below, so the same
|
||||
/// rules apply whether an instance is created natively (owned path/seed data)
|
||||
/// or reconstructed from legacy data during adoption
|
||||
/// (New.Infrastructure.Legacy.LegacyAanvraagMapper calls straight into these
|
||||
/// same methods and lets domain exceptions propagate as mapping failures).
|
||||
/// </summary>
|
||||
public sealed class RegistrationApplication
|
||||
{
|
||||
public Guid RegistrationApplicationId { get; }
|
||||
public Bsn Bsn { get; private set; }
|
||||
public PersonName Applicant { get; private set; }
|
||||
public Address? CorrespondenceAddress { get; private set; }
|
||||
public ContactDetails ContactDetails { get; private set; }
|
||||
public DiplomaEvidence DiplomaEvidence { get; private set; }
|
||||
public Assessment? Assessment { get; private set; }
|
||||
public DateOnly ReceivedOn { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation to the case-framework case. Nullable here - a deliberate,
|
||||
/// documented deviation from the aggregate's conceptual model, where a
|
||||
/// case reference is always expected: during adoption (take-ownership),
|
||||
/// the mapping to a valid <see cref="RegistrationApplication"/> (step 3)
|
||||
/// must succeed and fail fast BEFORE the case-framework case is created
|
||||
/// (step 4 - see the take-ownership handler), so there is a real,
|
||||
/// unavoidable moment where a fully-valid aggregate exists with no case
|
||||
/// reference yet. <see cref="AttachCaseReference"/> fills it in
|
||||
/// immediately after, before anything is persisted.
|
||||
/// </summary>
|
||||
public CaseReference? Case { get; private set; }
|
||||
|
||||
private RegistrationApplication(
|
||||
Guid registrationApplicationId,
|
||||
Bsn bsn,
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails,
|
||||
DiplomaEvidence diplomaEvidence,
|
||||
DateOnly receivedOn,
|
||||
CaseReference? caseReference,
|
||||
Assessment? assessment)
|
||||
{
|
||||
RegistrationApplicationId = registrationApplicationId;
|
||||
Bsn = bsn;
|
||||
Applicant = applicant;
|
||||
CorrespondenceAddress = correspondenceAddress;
|
||||
ContactDetails = contactDetails;
|
||||
DiplomaEvidence = diplomaEvidence;
|
||||
ReceivedOn = receivedOn;
|
||||
Case = caseReference;
|
||||
Assessment = assessment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new application. Used both for genuinely native creation and
|
||||
/// by the legacy mapper during adoption (with <paramref name="caseReference"/>
|
||||
/// left null, attached afterwards via <see cref="AttachCaseReference"/>).
|
||||
/// </summary>
|
||||
public static RegistrationApplication Create(
|
||||
Guid registrationApplicationId,
|
||||
Bsn bsn,
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails,
|
||||
DiplomaEvidence diplomaEvidence,
|
||||
DateOnly receivedOn,
|
||||
CaseReference? caseReference = null)
|
||||
{
|
||||
if (registrationApplicationId == Guid.Empty)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"RegistrationApplication.IdRequired",
|
||||
"A registration application must have a non-empty id.");
|
||||
}
|
||||
|
||||
return new RegistrationApplication(
|
||||
registrationApplicationId,
|
||||
bsn,
|
||||
applicant,
|
||||
correspondenceAddress,
|
||||
contactDetails,
|
||||
diplomaEvidence,
|
||||
receivedOn,
|
||||
caseReference,
|
||||
assessment: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs an application with a pre-existing assessment (used when
|
||||
/// rehydrating from storage, or when adopting an already-assessed legacy
|
||||
/// case). Goes through the same <see cref="Assessment.Create"/> validation.
|
||||
/// </summary>
|
||||
public static RegistrationApplication CreateWithAssessment(
|
||||
Guid registrationApplicationId,
|
||||
Bsn bsn,
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails,
|
||||
DiplomaEvidence diplomaEvidence,
|
||||
DateOnly receivedOn,
|
||||
Assessment assessment,
|
||||
CaseReference? caseReference = null)
|
||||
{
|
||||
var application = Create(
|
||||
registrationApplicationId,
|
||||
bsn,
|
||||
applicant,
|
||||
correspondenceAddress,
|
||||
contactDetails,
|
||||
diplomaEvidence,
|
||||
receivedOn,
|
||||
caseReference);
|
||||
|
||||
application.Assessment = assessment;
|
||||
return application;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches this aggregate to its case-framework case. Callable exactly
|
||||
/// once - see the class remarks on <see cref="Case"/> for why this exists
|
||||
/// as a separate step instead of a constructor parameter.
|
||||
/// </summary>
|
||||
public void AttachCaseReference(CaseReference caseReference)
|
||||
{
|
||||
if (Case is not null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"RegistrationApplication.CaseAlreadyAttached",
|
||||
"This application is already correlated to a case-framework case.");
|
||||
}
|
||||
|
||||
Case = caseReference;
|
||||
}
|
||||
|
||||
/// <summary>Updates the case-framework's own process status mirror.</summary>
|
||||
public void UpdateProcessStatus(string? processStatus)
|
||||
{
|
||||
if (Case is null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"RegistrationApplication.CaseNotAttached",
|
||||
"Cannot update process status before a case reference is attached.");
|
||||
}
|
||||
|
||||
Case = Case.WithProcessStatus(processStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits the applicant-facing details through the owned path (i.e. not
|
||||
/// the legacy write-through seam). Re-validates every invariant exactly
|
||||
/// like construction does, since these are the same value objects.
|
||||
/// </summary>
|
||||
public void UpdateApplicantDetails(
|
||||
PersonName applicant,
|
||||
Address? correspondenceAddress,
|
||||
ContactDetails contactDetails)
|
||||
{
|
||||
Applicant = applicant;
|
||||
CorrespondenceAddress = correspondenceAddress;
|
||||
ContactDetails = contactDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the outcome of an assessment. All validation lives in
|
||||
/// <see cref="Assessment.Create"/> - this method's job is purely to apply
|
||||
/// the result to the aggregate.
|
||||
/// </summary>
|
||||
public void RecordAssessment(
|
||||
AssessmentOutcome outcome,
|
||||
string motivation,
|
||||
IReadOnlyList<string>? verifiedItems,
|
||||
string? exceptionReason,
|
||||
string? rejectionCategory,
|
||||
DateOnly decidedOn)
|
||||
{
|
||||
Assessment = Assessment.Create(outcome, motivation, verifiedItems, exceptionReason, rejectionCategory, decidedOn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// A correspondence address. All four parts are required by this constructor
|
||||
/// on purpose: the "all four or nothing" rule (a partial address is no
|
||||
/// address) is enforced by never letting callers construct a partial
|
||||
/// instance, not by making the parts nullable here. Callers that only have
|
||||
/// partial data (e.g. the legacy mapper facing four independently-nullable
|
||||
/// columns) decide whether to construct an <see cref="Address"/> at all -
|
||||
/// see <see cref="RegistrationApplication.CorrespondenceAddress"/>, which is
|
||||
/// itself nullable for exactly this reason.
|
||||
/// </summary>
|
||||
public sealed record Address
|
||||
{
|
||||
public string Street { get; }
|
||||
public string Number { get; }
|
||||
public string PostalCode { get; }
|
||||
public string City { get; }
|
||||
|
||||
public Address(string street, string number, string postalCode, string city)
|
||||
{
|
||||
RequireNonBlank(street, "street");
|
||||
RequireNonBlank(number, "number");
|
||||
RequireNonBlank(postalCode, "postalCode");
|
||||
RequireNonBlank(city, "city");
|
||||
|
||||
Street = street;
|
||||
Number = number;
|
||||
PostalCode = postalCode;
|
||||
City = city;
|
||||
}
|
||||
|
||||
private static void RequireNonBlank(string value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Address.AllPartsRequired",
|
||||
$"Address.{fieldName} is required whenever an address is present (partial address = no address).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The decision on an application. Named <c>AssessmentOutcome</c> - never the
|
||||
/// bare word "Status" - to keep it distinct from the case-framework's own
|
||||
/// <c>ProcessStatus</c> and from the legacy `stat_cd` / `beoordRes` codes.
|
||||
/// Also stands in for what the case-framework calls a "Decision" document.
|
||||
/// </summary>
|
||||
public enum AssessmentOutcome
|
||||
{
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recorded assessment of a <see cref="RegistrationApplication"/>. Only ever
|
||||
/// constructed through <see cref="Create"/>, which enforces every invariant
|
||||
/// so the same validation applies whether the assessment is entered natively
|
||||
/// through the owned path or reconstructed from legacy data during adoption.
|
||||
/// </summary>
|
||||
public sealed record Assessment
|
||||
{
|
||||
private const int DefaultMinimumMotivationLength = 20;
|
||||
private const int OtherCategoryMinimumMotivationLength = 50;
|
||||
|
||||
public AssessmentOutcome Outcome { get; }
|
||||
public string Motivation { get; }
|
||||
public IReadOnlyList<string> VerifiedItems { get; }
|
||||
public string? ExceptionReason { get; }
|
||||
public string? RejectionCategory { get; }
|
||||
public DateOnly DecidedOn { get; }
|
||||
|
||||
private Assessment(
|
||||
AssessmentOutcome outcome,
|
||||
string motivation,
|
||||
IReadOnlyList<string> verifiedItems,
|
||||
string? exceptionReason,
|
||||
string? rejectionCategory,
|
||||
DateOnly decidedOn)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Motivation = motivation;
|
||||
VerifiedItems = verifiedItems;
|
||||
ExceptionReason = exceptionReason;
|
||||
RejectionCategory = rejectionCategory;
|
||||
DecidedOn = decidedOn;
|
||||
}
|
||||
|
||||
public static Assessment Create(
|
||||
AssessmentOutcome outcome,
|
||||
string motivation,
|
||||
IReadOnlyList<string>? verifiedItems,
|
||||
string? exceptionReason,
|
||||
string? rejectionCategory,
|
||||
DateOnly decidedOn)
|
||||
{
|
||||
var items = verifiedItems ?? Array.Empty<string>();
|
||||
|
||||
// Recording an assessment requires either every diploma evidence item
|
||||
// to have been verified, or a recorded reason why verification was
|
||||
// skipped - never neither.
|
||||
if (items.Count == 0 && string.IsNullOrWhiteSpace(exceptionReason))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Assessment.VerificationRequired",
|
||||
"Recording an assessment requires either at least one verified item or a recorded exception reason.");
|
||||
}
|
||||
|
||||
// rejectionCategory only makes sense - and is only carried - alongside
|
||||
// a Rejected outcome.
|
||||
var normalizedRejectionCategory = outcome == AssessmentOutcome.Rejected
|
||||
? rejectionCategory
|
||||
: null;
|
||||
|
||||
if (outcome == AssessmentOutcome.Rejected && string.IsNullOrWhiteSpace(normalizedRejectionCategory))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Assessment.RejectionCategoryRequired",
|
||||
"A rejection category is required when the outcome is Rejected.");
|
||||
}
|
||||
|
||||
var minimumLength = IsOtherCategory(normalizedRejectionCategory)
|
||||
? OtherCategoryMinimumMotivationLength
|
||||
: DefaultMinimumMotivationLength;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(motivation) || motivation.Trim().Length < minimumLength)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Assessment.MotivationTooShort",
|
||||
$"The motivation must be at least {minimumLength} characters long" +
|
||||
(IsOtherCategory(normalizedRejectionCategory) ? " when the rejection category is 'Other'." : "."));
|
||||
}
|
||||
|
||||
return new Assessment(outcome, motivation, items, exceptionReason, normalizedRejectionCategory, decidedOn);
|
||||
}
|
||||
|
||||
private static bool IsOtherCategory(string? rejectionCategory) =>
|
||||
rejectionCategory is not null &&
|
||||
(string.Equals(rejectionCategory, "Other", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(rejectionCategory, "anders", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// A Dutch "burgerservicenummer" - always exactly 9 digits, validated with the
|
||||
/// eleven-proof (elfproef) checksum.
|
||||
///
|
||||
/// This value object deliberately does NOT trim or otherwise massage its
|
||||
/// input. The legacy source stores BSNs as a space-padded CHAR(9), and it is
|
||||
/// the legacy mapper's job (New.Infrastructure.Legacy.LegacyAanvraagMapper)
|
||||
/// to trim before handing the raw value to this constructor - if it forgets,
|
||||
/// this constructor throws, which is the point: silently accepting padded
|
||||
/// input here would hide that mapping bug instead of surfacing it.
|
||||
/// </summary>
|
||||
public sealed record Bsn
|
||||
{
|
||||
public string Value { get; }
|
||||
|
||||
public Bsn(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length != 9 || !value.All(char.IsDigit))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Bsn.Format",
|
||||
$"A BSN must be exactly 9 digits. Got '{value}'.");
|
||||
}
|
||||
|
||||
if (!PassesElevenProof(value))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Bsn.ElevenProof",
|
||||
$"'{value}' does not pass the eleven-proof (elfproef) checksum.");
|
||||
}
|
||||
|
||||
// All-zero digits trivially satisfy the eleven-proof formula (every
|
||||
// weighted term is zero) but "000000000" has never been an issued
|
||||
// BSN - real BSN validation excludes it explicitly, not just via the
|
||||
// checksum.
|
||||
if (value == "000000000")
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Bsn.ElevenProof",
|
||||
"'000000000' is not a valid BSN.");
|
||||
}
|
||||
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (9*d1 + 8*d2 + 7*d3 + 6*d4 + 5*d5 + 4*d6 + 3*d7 + 2*d8 - 1*d9) % 11 == 0
|
||||
/// </summary>
|
||||
private static bool PassesElevenProof(string digits)
|
||||
{
|
||||
var sum = 0;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var weight = 9 - i;
|
||||
sum += weight * (digits[i] - '0');
|
||||
}
|
||||
|
||||
sum -= digits[8] - '0';
|
||||
|
||||
return sum % 11 == 0;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Pure correlation to the case-framework's own case - deliberately NOT
|
||||
/// inheritance and NOT a base class. <see cref="RegistrationApplication"/>
|
||||
/// has-a <see cref="CaseReference"/>, it is not-a case-framework case.
|
||||
///
|
||||
/// <see cref="ProcessStatus"/> mirrors the case-framework's own process state
|
||||
/// (its vocabulary, e.g. "InBehandeling"/"Afgesloten") purely for display -
|
||||
/// it is intentionally never named just "Status", to keep it distinct from
|
||||
/// this application's own <see cref="AssessmentOutcome"/> decision.
|
||||
/// </summary>
|
||||
public sealed record CaseReference
|
||||
{
|
||||
public Guid FrameworkCaseId { get; }
|
||||
public string ExternalReference { get; }
|
||||
public string? ProcessStatus { get; }
|
||||
|
||||
public CaseReference(Guid frameworkCaseId, string externalReference, string? processStatus)
|
||||
{
|
||||
if (frameworkCaseId == Guid.Empty)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"CaseReference.FrameworkCaseIdRequired",
|
||||
"A case reference must point at a real case-framework case.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalReference))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"CaseReference.ExternalReferenceRequired",
|
||||
"A case reference must carry the external reference it was correlated by.");
|
||||
}
|
||||
|
||||
FrameworkCaseId = frameworkCaseId;
|
||||
ExternalReference = externalReference;
|
||||
ProcessStatus = processStatus;
|
||||
}
|
||||
|
||||
public CaseReference WithProcessStatus(string? processStatus) =>
|
||||
new(FrameworkCaseId, ExternalReference, processStatus);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>How the applicant prefers to be contacted.</summary>
|
||||
public enum CorrespondenceChannel
|
||||
{
|
||||
Post,
|
||||
Email,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email/phone plus the applicant's preferred channel. The one real invariant:
|
||||
/// choosing <see cref="CorrespondenceChannel.Email"/> requires a non-empty,
|
||||
/// well-formed email address - you can't ask to be emailed with no email on file.
|
||||
/// </summary>
|
||||
public sealed record ContactDetails
|
||||
{
|
||||
private static readonly Regex SimpleEmailPattern =
|
||||
new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);
|
||||
|
||||
public string? Email { get; }
|
||||
public string? Phone { get; }
|
||||
public CorrespondenceChannel PreferredChannel { get; }
|
||||
|
||||
public ContactDetails(string? email, string? phone, CorrespondenceChannel preferredChannel)
|
||||
{
|
||||
if (preferredChannel == CorrespondenceChannel.Email && !IsWellFormedEmail(email))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"ContactDetails.EmailRequiredForEmailChannel",
|
||||
"Preferring email as the correspondence channel requires a non-empty, well-formed email address.");
|
||||
}
|
||||
|
||||
Email = email;
|
||||
Phone = phone;
|
||||
PreferredChannel = preferredChannel;
|
||||
}
|
||||
|
||||
private static bool IsWellFormedEmail(string? email) =>
|
||||
!string.IsNullOrWhiteSpace(email) && SimpleEmailPattern.IsMatch(email);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>Evidence of a foreign diploma submitted in support of the application.</summary>
|
||||
public sealed record DiplomaEvidence
|
||||
{
|
||||
public string Code { get; }
|
||||
public string CountryOfIssue { get; }
|
||||
public DateOnly IssuedOn { get; }
|
||||
|
||||
public DiplomaEvidence(string code, string countryOfIssue, DateOnly issuedOn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"DiplomaEvidence.CodeRequired",
|
||||
"A diploma evidence code is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(countryOfIssue))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"DiplomaEvidence.CountryOfIssueRequired",
|
||||
"A country of issue is required.");
|
||||
}
|
||||
|
||||
Code = code;
|
||||
CountryOfIssue = countryOfIssue;
|
||||
IssuedOn = issuedOn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace New.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The applicant's name. Deliberately called <c>PersonName</c>/<c>Applicant</c>
|
||||
/// rather than the case-framework's "Participant" - see the false-cognates
|
||||
/// table in the migration design notes.
|
||||
/// </summary>
|
||||
public sealed record PersonName
|
||||
{
|
||||
public string Surname { get; }
|
||||
public string Initials { get; }
|
||||
|
||||
public PersonName(string surname, string initials)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(surname))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"PersonName.SurnameRequired",
|
||||
"A surname is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(initials))
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"PersonName.InitialsRequired",
|
||||
"Initials are required.");
|
||||
}
|
||||
|
||||
Surname = surname;
|
||||
Initials = initials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using New.Infrastructure.CaseFramework.Dtos;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework;
|
||||
|
||||
/// <summary>Thin wrapper around the case-framework HttpClient - the one place that knows its exact routes.</summary>
|
||||
internal sealed class CaseFrameworkClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<CreateCaseResponse> CreateCaseAsync(CreateCaseRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsJsonAsync("/cases", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<CreateCaseResponse>(ct))!;
|
||||
}
|
||||
|
||||
public async Task<CaseResponse?> GetCaseAsync(Guid caseId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.GetAsync($"/cases/{caseId}", ct);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<CaseResponse>(ct);
|
||||
}
|
||||
|
||||
public async Task<CreateTaskResponse> CreateTaskAsync(Guid caseId, CreateTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsJsonAsync($"/cases/{caseId}/tasks", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<CreateTaskResponse>(ct))!;
|
||||
}
|
||||
|
||||
public async Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsync($"/cases/{caseId}/tasks/{taskId}/complete", content: null, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the case closed (204), false if the framework returned 409 (an open task).</summary>
|
||||
public async Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PostAsync($"/cases/{caseId}/closure-request", content: null, ct);
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.CaseFramework.Dtos;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework;
|
||||
|
||||
/// <summary>Seam D: implements <see cref="ICaseFrameworkGateway"/> against the case-framework's own contract.</summary>
|
||||
public sealed class CaseFrameworkGateway : ICaseFrameworkGateway
|
||||
{
|
||||
private readonly CaseFrameworkClient _client;
|
||||
|
||||
// Internal constructor parameter type (CaseFrameworkClient is internal -
|
||||
// its API is shaped by case-framework DTOs). Registered via an explicit
|
||||
// factory in ServiceCollectionExtensions; see that file's remarks.
|
||||
internal CaseFrameworkGateway(CaseFrameworkClient client) => _client = client;
|
||||
|
||||
public async Task<CaseCreated> CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList<string> participants, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.CreateCaseAsync(
|
||||
new CreateCaseRequest(caseTypeCode, externalReference, participants.ToList()), ct);
|
||||
return new CaseCreated(response.Id, response.ProcessStatus);
|
||||
}
|
||||
|
||||
public async Task<string?> GetProcessStatusAsync(Guid caseId, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.GetCaseAsync(caseId, ct);
|
||||
return response?.ProcessStatus;
|
||||
}
|
||||
|
||||
public async Task<TaskCreated> CreateTaskAsync(Guid caseId, string code, string description, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.CreateTaskAsync(caseId, new CreateTaskRequest(code, description), ct);
|
||||
return new TaskCreated(response.TaskId, response.Open);
|
||||
}
|
||||
|
||||
public Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct) =>
|
||||
_client.CompleteTaskAsync(caseId, taskId, ct);
|
||||
|
||||
public Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct) =>
|
||||
_client.RequestClosureAsync(caseId, ct);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Case-framework's own wire shapes, exactly as documented in the migration
|
||||
/// design notes. Internal to this project - nothing outside
|
||||
/// New.Infrastructure.CaseFramework may reference these types
|
||||
/// (Architecture.Tests rule 4).
|
||||
/// </summary>
|
||||
internal sealed record CreateCaseRequest(
|
||||
[property: JsonPropertyName("caseTypeCode")] string CaseTypeCode,
|
||||
[property: JsonPropertyName("externalReference")] string ExternalReference,
|
||||
[property: JsonPropertyName("participants")] List<string> Participants);
|
||||
|
||||
internal sealed record CreateCaseResponse(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("processStatus")] string? ProcessStatus);
|
||||
|
||||
internal sealed record CaseResponse(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("caseTypeCode")] string CaseTypeCode,
|
||||
[property: JsonPropertyName("externalReference")] string ExternalReference,
|
||||
[property: JsonPropertyName("processStatus")] string? ProcessStatus,
|
||||
[property: JsonPropertyName("participants")] List<string> Participants);
|
||||
|
||||
internal sealed record TimelineEntryResponse(
|
||||
[property: JsonPropertyName("at")] DateTimeOffset At,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("description")] string Description);
|
||||
|
||||
internal sealed record TimelineResponse(
|
||||
[property: JsonPropertyName("entries")] List<TimelineEntryResponse> Entries);
|
||||
|
||||
internal sealed record CreateTaskRequest(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("description")] string Description);
|
||||
|
||||
internal sealed record CreateTaskResponse(
|
||||
[property: JsonPropertyName("taskId")] Guid TaskId,
|
||||
[property: JsonPropertyName("open")] bool Open);
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Infrastructure.CaseFramework</RootNamespace>
|
||||
<!--
|
||||
Case-framework DTOs in this project are `internal` on purpose
|
||||
(Architecture.Tests rule 4). See New.Infrastructure.Legacy.csproj for
|
||||
why no InternalsVisibleTo is needed for the architecture tests to see them.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace New.Infrastructure.CaseFramework.Options;
|
||||
|
||||
public sealed class CaseFrameworkOptions
|
||||
{
|
||||
public const string SectionName = "Services:CaseFramework";
|
||||
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.CaseFramework.Options;
|
||||
|
||||
namespace New.Infrastructure.CaseFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for this project - see
|
||||
/// New.Infrastructure.Persistence.ServiceCollectionExtensions for the
|
||||
/// rationale (Program.cs never names concrete infra types directly), and
|
||||
/// New.Infrastructure.Legacy.ServiceCollectionExtensions for why the
|
||||
/// CaseFrameworkClient-dependent registration below uses an explicit factory
|
||||
/// delegate rather than relying on reflection-based auto-construction.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string HttpClientName = "CaseFramework";
|
||||
|
||||
public static IServiceCollection AddCaseFrameworkInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<CaseFrameworkOptions>(configuration.GetSection(CaseFrameworkOptions.SectionName));
|
||||
|
||||
services.AddHttpClient(HttpClientName, (sp, http) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<CaseFrameworkOptions>>().Value;
|
||||
http.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new CaseFrameworkClient(sp.GetRequiredService<IHttpClientFactory>().CreateClient(HttpClientName)));
|
||||
|
||||
services.AddScoped<ICaseFrameworkGateway>(sp =>
|
||||
new CaseFrameworkGateway(sp.GetRequiredService<CaseFrameworkClient>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's `mutDat` is a local Europe/Amsterdam DATETIME2 with no offset -
|
||||
/// converting it to a UTC-backed DateTimeOffset requires explicitly applying
|
||||
/// this time zone (including DST), never assuming it's already UTC (which
|
||||
/// would silently shift every audit timestamp by 1-2 hours).
|
||||
/// </summary>
|
||||
internal static class AmsterdamClock
|
||||
{
|
||||
private static readonly TimeZoneInfo Amsterdam = TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam");
|
||||
|
||||
public static DateTimeOffset ToUtcOffset(DateTime localUnspecified)
|
||||
{
|
||||
var unspecified = DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified);
|
||||
var utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, Amsterdam);
|
||||
return new DateTimeOffset(utc, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's own row shape, exactly as documented in the migration design
|
||||
/// notes, plus an `id` field: the quoted contract doesn't list it explicitly,
|
||||
/// but GET /api/aanvragen/{id} is id-addressed, so the row necessarily
|
||||
/// carries its own id. Internal to this project - nothing outside
|
||||
/// New.Infrastructure.Legacy may reference this type (Architecture.Tests rule 3).
|
||||
/// </summary>
|
||||
internal sealed record LegacyAanvraagDto(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("bsn")] string Bsn,
|
||||
[property: JsonPropertyName("naam")] string Naam,
|
||||
[property: JsonPropertyName("voorl")] string Voorl,
|
||||
[property: JsonPropertyName("adresStr")] string? AdresStr,
|
||||
[property: JsonPropertyName("adresNr")] string? AdresNr,
|
||||
[property: JsonPropertyName("adresPc")] string? AdresPc,
|
||||
[property: JsonPropertyName("adresPl")] string? AdresPl,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("telnr")] string? Telnr,
|
||||
[property: JsonPropertyName("corrKanaal")] string CorrKanaal,
|
||||
[property: JsonPropertyName("statCd")] string StatCd,
|
||||
[property: JsonPropertyName("diplCd")] string DiplCd,
|
||||
[property: JsonPropertyName("diplLand")] string DiplLand,
|
||||
[property: JsonPropertyName("diplDat")] DateOnly DiplDat,
|
||||
[property: JsonPropertyName("datOntv")] DateOnly DatOntv,
|
||||
[property: JsonPropertyName("datBeoord")] DateOnly? DatBeoord,
|
||||
[property: JsonPropertyName("beoordRes")] string? BeoordRes,
|
||||
[property: JsonPropertyName("beoordMotiv")] string? BeoordMotiv,
|
||||
[property: JsonPropertyName("migrated")] bool Migrated,
|
||||
[property: JsonPropertyName("mutDat")] DateTime MutDat,
|
||||
[property: JsonPropertyName("mutUser")] string? MutUser);
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
/// <summary>Seam B request body - legacy was told to accept exactly this portal-facing shape.</summary>
|
||||
internal sealed record LegacyDetailsWriteRequest(
|
||||
[property: JsonPropertyName("surname")] string Surname,
|
||||
[property: JsonPropertyName("initials")] string Initials,
|
||||
[property: JsonPropertyName("address")] LegacyAddressWriteRequest? Address,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("phone")] string? Phone,
|
||||
[property: JsonPropertyName("preferredChannel")] string PreferredChannel);
|
||||
|
||||
internal sealed record LegacyAddressWriteRequest(
|
||||
[property: JsonPropertyName("street")] string Street,
|
||||
[property: JsonPropertyName("number")] string Number,
|
||||
[property: JsonPropertyName("postalCode")] string PostalCode,
|
||||
[property: JsonPropertyName("city")] string City);
|
||||
|
||||
internal sealed record LegacyValidationErrorResponse(
|
||||
[property: JsonPropertyName("errors")] List<LegacyValidationError> Errors);
|
||||
|
||||
internal sealed record LegacyValidationError(
|
||||
[property: JsonPropertyName("veld")] string Veld,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("melding")] string Melding);
|
||||
|
||||
internal sealed record MigratieVlagRequest(
|
||||
[property: JsonPropertyName("migrated")] bool Migrated);
|
||||
@@ -0,0 +1,132 @@
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a legacy row to a <see cref="RegistrationApplication"/>. Every
|
||||
/// invariant is enforced by calling straight into the domain's own
|
||||
/// validating constructors/factories - none of this mapping logic lives in
|
||||
/// New.Domain, and a domain exception thrown here is exactly the signal the
|
||||
/// take-ownership handler needs to fail adoption with a named invariant.
|
||||
///
|
||||
/// Each numbered comment below is a deliberate defect trap this mapper must
|
||||
/// not fall into.
|
||||
/// </summary>
|
||||
internal static class LegacyAanvraagMapper
|
||||
{
|
||||
public static RegistrationApplication ToDomain(LegacyAanvraagDto dto)
|
||||
{
|
||||
// 1) bsn is a space-padded CHAR(9) in the source. The padding is
|
||||
// invisible in JSON output but breaks the eleven-proof check if not
|
||||
// trimmed - Bsn's constructor deliberately does NOT trim, so this
|
||||
// Trim() is load-bearing, not defensive fluff.
|
||||
var bsn = new Bsn(dto.Bsn.Trim());
|
||||
|
||||
var applicant = new PersonName(dto.Naam, dto.Voorl);
|
||||
|
||||
// 2) statCd must map to a named enum; an unrecognized code throws
|
||||
// rather than silently defaulting. Not stored on the domain aggregate
|
||||
// (it has no business meaning there - see New.Infrastructure.Legacy.LegacyAanvraagStatus)
|
||||
// but still validated here as a data-quality gate before adoption proceeds.
|
||||
LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
|
||||
// 3) corrKanaal's legacy 'P' default is a "nobody actively chose"
|
||||
// sentinel, not evidence of a real preference - it still maps to
|
||||
// Post for display, we just never treat its mere presence as proof
|
||||
// of anything. An unrecognized channel throws rather than defaulting.
|
||||
var channel = dto.CorrKanaal switch
|
||||
{
|
||||
"P" => CorrespondenceChannel.Post,
|
||||
"E" => CorrespondenceChannel.Email,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"Legacy.UnrecognizedCorrKanaal", $"Unrecognized legacy corrKanaal '{dto.CorrKanaal}'."),
|
||||
};
|
||||
var contactDetails = new ContactDetails(dto.Email, dto.Telnr, channel);
|
||||
|
||||
// 4) four flat adres* columns -> Address?. Unlike the read-only
|
||||
// projection (LegacyCaseDetailProjection, which just displays legacy
|
||||
// data as-is), ADOPTION must fail loudly on a partial address rather
|
||||
// than silently treating it as "no address" - a partial address is a
|
||||
// real data-quality problem this row has, not a display nuance.
|
||||
Address? address = BuildAddressOrThrow(dto);
|
||||
|
||||
var diploma = new DiplomaEvidence(dto.DiplCd, dto.DiplLand, dto.DiplDat);
|
||||
var receivedOn = dto.DatOntv;
|
||||
|
||||
// 5) migrated is a bool - no implicit int conversion assumed (the
|
||||
// DTO already binds it as `bool` from JSON, so there is nothing to
|
||||
// coerce here; this comment documents that the trap was considered,
|
||||
// not skipped).
|
||||
_ = dto.Migrated;
|
||||
|
||||
if (dto.BeoordRes is null)
|
||||
{
|
||||
return RegistrationApplication.Create(
|
||||
Guid.NewGuid(), bsn, applicant, address, contactDetails, diploma, receivedOn);
|
||||
}
|
||||
|
||||
var outcome = dto.BeoordRes switch
|
||||
{
|
||||
"G" => AssessmentOutcome.Approved,
|
||||
"A" => AssessmentOutcome.Rejected,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"Legacy.UnrecognizedBeoordRes", $"Unrecognized legacy beoordRes '{dto.BeoordRes}'."),
|
||||
};
|
||||
|
||||
if (dto.DatBeoord is null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Legacy.MissingBeoordelingsdatum", "A recorded beoordRes requires a datBeoord.");
|
||||
}
|
||||
|
||||
// Legacy has no granular per-item verification checklist and no
|
||||
// rejection-category taxonomy - both are owned-side-only concepts.
|
||||
// We synthesize the minimum the domain requires to represent "this
|
||||
// was already assessed, verified through legacy's own (unmodeled)
|
||||
// process": an exception reason standing in for verifiedItems, and -
|
||||
// only for a Rejected outcome - a rejection category that is
|
||||
// deliberately NOT "Other"/"anders", so beoordMotiv is held to the
|
||||
// domain's normal 20-char minimum rather than the 50-char "Other"
|
||||
// minimum. (6) beoordMotiv may be shorter than that minimum - that's
|
||||
// expected, and Assessment.Create below will throw for it, which is
|
||||
// exactly the "surface as an adoption failure" behavior required.
|
||||
const string legacyVerificationNote = "Migrated from legacy system; verification recorded in legacy's own audit trail.";
|
||||
var rejectionCategory = outcome == AssessmentOutcome.Rejected ? "LegacyRejection" : null;
|
||||
|
||||
var application = RegistrationApplication.Create(
|
||||
Guid.NewGuid(), bsn, applicant, address, contactDetails, diploma, receivedOn);
|
||||
|
||||
application.RecordAssessment(
|
||||
outcome,
|
||||
dto.BeoordMotiv ?? string.Empty,
|
||||
verifiedItems: [],
|
||||
exceptionReason: legacyVerificationNote,
|
||||
rejectionCategory,
|
||||
dto.DatBeoord.Value);
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private static Address? BuildAddressOrThrow(LegacyAanvraagDto dto)
|
||||
{
|
||||
var parts = new[] { dto.AdresStr, dto.AdresNr, dto.AdresPc, dto.AdresPl };
|
||||
var presentCount = parts.Count(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (presentCount == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presentCount < parts.Length)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Address.AllPartsRequired",
|
||||
"This legacy row has a partial address (some but not all of street/number/postal code/city). " +
|
||||
"Adoption requires a complete address or none at all.");
|
||||
}
|
||||
|
||||
return new Address(dto.AdresStr!, dto.AdresNr!, dto.AdresPc!, dto.AdresPl!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's own `statCd` vocabulary ('O'|'B'|'A'|'X'), named - never left as
|
||||
/// bare characters. Used only for display (worklist bucket / process
|
||||
/// status), never as part of the domain aggregate: New.Domain has no
|
||||
/// business rules keyed on legacy's process stage, only on its own
|
||||
/// AssessmentOutcome once an assessment is actually recorded.
|
||||
/// </summary>
|
||||
internal enum LegacyAanvraagStatus
|
||||
{
|
||||
Open,
|
||||
Beoordeeld,
|
||||
Afgerond,
|
||||
Ingetrokken,
|
||||
}
|
||||
|
||||
internal static class LegacyAanvraagStatusMapper
|
||||
{
|
||||
public static LegacyAanvraagStatus Parse(string statCd) => statCd switch
|
||||
{
|
||||
"O" => LegacyAanvraagStatus.Open,
|
||||
"B" => LegacyAanvraagStatus.Beoordeeld,
|
||||
"A" => LegacyAanvraagStatus.Afgerond,
|
||||
"X" => LegacyAanvraagStatus.Ingetrokken,
|
||||
_ => throw new DomainInvariantViolationException("Legacy.UnrecognizedStatCd", $"Unrecognized legacy statCd '{statCd}'."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Thin wrapper around the legacy-backend HttpClient - the one place that knows its exact routes.</summary>
|
||||
internal sealed class LegacyBackendClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<LegacyAanvraagDto?> GetAanvraagAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.GetAsync($"/api/aanvragen/{aanvraagId}", ct);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<LegacyAanvraagDto>(ct);
|
||||
}
|
||||
|
||||
public async Task<List<LegacyAanvraagDto>> ListAanvragenAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<List<LegacyAanvraagDto>>("/api/aanvragen", ct);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task<LegacyDetailsWriteResponse> UpdateDetailsAsync(int aanvraagId, LegacyDetailsWriteRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PutAsJsonAsync($"/api/aanvragen/{aanvraagId}/gegevens", request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.Success();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.NotFound();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.Conflict();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadFromJsonAsync<LegacyValidationErrorResponse>(ct);
|
||||
return LegacyDetailsWriteResponse.ValidationFailed(body?.Errors ?? []);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
throw new InvalidOperationException("Unreachable - EnsureSuccessStatusCode throws for any non-2xx status.");
|
||||
}
|
||||
|
||||
public async Task SetMigratieVlagAsync(int aanvraagId, bool migrated, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PutAsJsonAsync(
|
||||
$"/api/aanvragen/{aanvraagId}/migratie-vlag", new MigratieVlagRequest(migrated), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record LegacyDetailsWriteResponse(
|
||||
LegacyDetailsWriteOutcome Outcome,
|
||||
List<LegacyValidationError>? Errors = null)
|
||||
{
|
||||
public static LegacyDetailsWriteResponse Success() => new(LegacyDetailsWriteOutcome.Success);
|
||||
public static LegacyDetailsWriteResponse NotFound() => new(LegacyDetailsWriteOutcome.NotFound);
|
||||
public static LegacyDetailsWriteResponse Conflict() => new(LegacyDetailsWriteOutcome.Conflict);
|
||||
|
||||
public static LegacyDetailsWriteResponse ValidationFailed(List<LegacyValidationError> errors) =>
|
||||
new(LegacyDetailsWriteOutcome.ValidationFailed, errors);
|
||||
}
|
||||
|
||||
internal enum LegacyDetailsWriteOutcome
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
Conflict,
|
||||
ValidationFailed,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// In-process counter of actual legacy HTTP calls, backing GET
|
||||
/// /api/diagnostics/legacy-call-count. Incremented exclusively by
|
||||
/// <see cref="LegacyCallCountingHandler"/> - a DelegatingHandler on the
|
||||
/// legacy-backend HttpClient - so every call through this client counts,
|
||||
/// with no risk of a call site forgetting to increment it by hand.
|
||||
/// </summary>
|
||||
public sealed class LegacyCallCounter
|
||||
{
|
||||
private long _count;
|
||||
|
||||
public long Count => Interlocked.Read(ref _count);
|
||||
|
||||
internal void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
internal sealed class LegacyCallCountingHandler(LegacyCallCounter counter) : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
counter.Increment();
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user