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