Compare commits

...
4 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 ae7781efef docs: close WP-72..75, regenerate behaviour spec
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 1m9s
CI / frontend (push) Successful in 2m27s
CI / backend (push) Successful in 1m56s
CI / e2e (push) Successful in 3m16s
CI / semgrep (push) Successful in 1m7s
CI / api-client-drift (push) Successful in 1m50s
CI / storybook-a11y (push) Successful in 11m4s
Four close-outs and their README rows. The behaviour spec is regenerated
once here rather than per-track — it derives from every test name in the
repo, so any track running it would have conflicted with the other three.

Records two findings the arc surfaced but did not cause: the /brief/preview
staleness for non-DemoOwner identities (blocking per-spec identity isolation
in brief-v2.spec.ts), and that WP-72/73 had to share a commit because both
edit Program.cs — separate execution waves prevented build collisions but
did not produce separable diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:34:54 +02:00
ehoandClaude Sonnet 5 6fa27d1c53 test: close the remaining FE/BE seams (WP-75)
Three seams WP-71 documented but left unguarded.

Deletes the FE's isHerregistratieEligible and isStatusConsistent — both
uncalled, the first dead by its own doc-comment. Their tests used fixtures
completely disjoint from the backend's (the backend even had an exact-window
boundary case the FE lacked), so the two sides could diverge indefinitely
without failing anything. CLAUDE.md's policy of keeping server-owned rules
as FE "reference impls" is what kept them alive, so it is amended: the FE may
mirror a server-supplied value for instant feedback, never reimplement the
algorithm. registration.policy.ts keeps its three live exports.

check-seam.sh now also guards the Besluit tag list — the C# enum and the TS
BESLUIT_TAGS array are identical ordered name lists with nothing linking
them, and Enum.TryParse fails at request time rather than build time. Anchored
on the full declaration so it avoids the "greps all matches" trap WP-69 hit.

The phone-format divergence turned out to be real, not latent as recorded:
the backend returned 422 for +31612345678 and (06) 12345678, both of which
the FE's own parseTelefoonnummer accepts. A grep check would have compared
the shared ^0\d{9}$ regex and reported all clear — the difference was in
stripping. RejectPhoneChange now strips what the FE strips, pinned by a
contract test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:32:22 +02:00
ehoandClaude Sonnet 5 42f7bd651d test(e2e): isolate runs and identities without a new backend endpoint (WP-74)
The three specs shared one mutable backend and said so in their own
comments ("Restart the backend between CI runs"). WP-70 recorded the fix as
a dev-only seed endpoint; it isn't needed. The DB path already routes
through IConfiguration, so playwright.config's webServer hands the backend a
throwaway SQLite file per invocation — the same trick TestWebApplicationFactory
already uses, with zero backend change. And StubIdentityProvider already
honoured X-Subject; the only gap was that nothing sent it. That matters
because the backend has no IsDevelopment() gate anywhere, so a seed endpoint
would have had to invent the codebase's first environment gate.

subjectInterceptor mirrors the existing roleInterceptor and is wired into the
same isDevMode()-only list. Interceptors alone were not enough: the raw XHR
upload and the hand-written letter-preview fetch bypass Angular's chain (as
CLAUDE.md documents), so both now stamp X-Subject explicitly — without that,
every uploaded document still landed under DemoOwner.

reuseExistingServer stays on: flipping it would break local runs for anyone
already serving the docker stack. Each run gets a unique DB filename and
global-setup sweeps only prior runs' leftovers — deleting a fixed path
mid-run risks SQLite silently recreating an empty, unmigrated file under
fullyParallel.

Verified: e2e passes twice back-to-back with no backend restart, and
X-Subject was observed on a real request, not merely wired.

brief-v2.spec.ts keeps the shared identity for now — see the KNOWN GAP note;
a backend staleness bug makes /brief/preview return a sent letter with the
draft watermark for any non-DemoOwner BSN. actors.ts reserves the actor for
whoever fixes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:32:07 +02:00
ehoandClaude Sonnet 5 6bc00a917c refactor(backend): delete dead legacy endpoints, make domain types unions (WP-72 + WP-73)
Two work packages in one commit because both edit Program.cs and splitting
them would leave a commit that does not build.

WP-72 — deletes POST /api/v1/intakes and /herregistraties. Both were dead
from the UI (the wizard submits via /applications/{id}/submit) and strictly
less capable: they minted a bare reference and wrote no Aanvraag, made no
ZGW call, and did no document-ownership check. The shared Submit(...) helper
survives — /registrations and /change-requests still use it. WP-69 hardened
/intakes with a 400 last session; removing the surface is the stronger fix,
and WP-69's /applications/{id}/submit enforcement is untouched.

WP-73 — RegistrationStatus becomes an abstract record with three sealed
variants behind a private base ctor, so only Geregistreerd carries a
herregistratie deadline and reden is required on Geschorst/Doorgehaald
(matching the FE union, which was already right). HerregistratieRule
.IsStatusConsistent and its test are deleted: the type now guarantees what
the runtime check was for, and the test could no longer construct the
illegal state it existed to catch.

Aanvraag splits into a Concept | Submitted | Decided union with the EF row
demoted to AanvraagEntity behind a two-way mapper. Submitted carries a
non-null Referentie and SubmittedAt, and Decided.Afgewezen/MeerInfoGevraagd
require a Toelichting — so the five Referentie! null-forgiving derefs in
StatusAt are gone, not merely suppressed. IZaakSource.CreateZaak narrows to
Aanvraag.Submitted, removing the same class of deref in both zaak sources.

Draft is now cleared on submit rather than lingering: ApplicationStore's
doc-comment claimed "Concept only" but Submit never cleared it. Verified
nothing reads a submitted aanvraag's draft (draft-sync's applyResume only
resumes unsubmitted wizards), so the comment is now true instead of
aspirational.

No migration, no schema change, no wire change — RegistrationStatusDto and
the application DTOs are byte-identical, confirmed against a live swagger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:31:38 +02:00
51 changed files with 1421 additions and 634 deletions
+3 -2
View File
@@ -178,8 +178,9 @@ FE keeps only **format** validation, never as authority.
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
but the FE doesn't call them.
rules live **only** on the server, with no FE mirror to drift from it — the FE may
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
never reimplements the _algorithm_.
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
+4 -1
View File
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
@@ -55,7 +56,9 @@ export const appConfig: ApplicationConfig = {
// a query param could otherwise force errors on the live app.
provideHttpClient(
withInterceptors(
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
isDevMode()
? [scenarioInterceptor, roleInterceptor, subjectInterceptor, medewerkerInterceptor]
: [],
),
),
provideApiClient(),
+6 -1
View File
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
@@ -54,7 +55,11 @@ export const appConfig: ApplicationConfig = {
),
// Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
provideHttpClient(
withInterceptors(
isDevMode() ? [scenarioInterceptor, roleInterceptor, subjectInterceptor] : [],
),
),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { currentSubject } from '@shared/infrastructure/subject';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
@@ -12,15 +13,37 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor`, so `X-Role` is set here explicitly.
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 — without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in).
*
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
* mutable resource served under one unversioned URL — independent of WP-74's
* identity work, and not a complete fix by itself: see the KNOWN GAP note below.
*
* KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* this repo's own e2e run against a real backend observed this endpoint's SENT
* response still carrying the draft watermark, even though (a) the outgoing request
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the
* same moment correctly returned the frozen, unwatermarked archive. `cache: 'no-store'`
* did not change the outcome, so it is very unlikely a client-side caching artifact —
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
* `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope —
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
* `zorgverlener` identity until this is root-caused).
*/
@Injectable({ providedIn: 'root' })
export class LetterPreviewAdapter {
async preview(): Promise<Result<string, Blob>> {
let res: Response;
try {
const subject = currentSubject();
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
headers: { 'X-Role': currentRole() },
cache: 'no-store',
headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) },
});
} catch {
return err(PREVIEW_FAILED);
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { Registration, RegistrationStatus } from './registration';
import { isHerregistratieEligible, isStatusConsistent, statusColor } from './registration.policy';
import { Registration } from './registration';
import { herregistratieDeadline, statusColor, statusLabel } from './registration.policy';
const reg = (status: Registration['status']): Registration => ({
bigNummer: '19012345601',
@@ -12,25 +12,10 @@ const reg = (status: Registration['status']): Registration => ({
});
describe('registration.policy', () => {
it('only an active registration within the window is eligible', () => {
const active = reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' });
expect(isHerregistratieEligible(active, new Date('2026-06-01'))).toBe(true); // within 12 months
expect(isHerregistratieEligible(active, new Date('2020-01-01'))).toBe(false); // too early
});
it('struck-off / suspended registrations are never eligible', () => {
expect(
isHerregistratieEligible(
reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }),
new Date('2027-01-01'),
),
).toBe(false);
expect(
isHerregistratieEligible(
reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }),
new Date('2027-01-01'),
),
).toBe(false);
it('statusLabel echoes the tag', () => {
expect(statusLabel('Geregistreerd')).toBe('Geregistreerd');
expect(statusLabel('Doorgehaald')).toBe('Doorgehaald');
expect(statusLabel('Geschorst')).toBe('Geschorst');
});
it('statusColor is total over the union', () => {
@@ -39,25 +24,15 @@ describe('registration.policy', () => {
expect(statusColor('Geschorst')).toContain('oranje');
});
it('a well-formed status is always consistent', () => {
it('herregistratieDeadline is only set for an active registration', () => {
expect(
isStatusConsistent(reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' }).status),
).toBe(true);
herregistratieDeadline(reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' })),
).toEqual(new Date('2027-01-01'));
expect(
isStatusConsistent(
reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }).status,
),
).toBe(true);
herregistratieDeadline(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' })),
).toBeNull();
expect(
isStatusConsistent(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }).status),
).toBe(true);
});
it('a Geregistreerd status without its herregistratieDatum is inconsistent', () => {
// The union itself makes this unrepresentable through normal construction (every
// Geregistreerd literal must carry a herregistratieDatum) — only reachable here by
// bypassing the type system, the way malformed runtime/serialized data could.
const malformed = { tag: 'Geregistreerd' } as unknown as RegistrationStatus;
expect(isStatusConsistent(malformed)).toBe(false);
herregistratieDeadline(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' })),
).toBeNull();
});
});
@@ -1,5 +1,5 @@
import { assertNever } from '@shared/kernel/fp';
import { Registration, RegistrationStatus, StatusTag } from './registration';
import { Registration, StatusTag } from './registration';
/**
* Domain logic for a registration — pure functions, NO Angular. This is where
@@ -32,27 +32,3 @@ export function statusColor(tag: StatusTag): string {
export function herregistratieDeadline(reg: Registration): Date | null {
return reg.status.tag === 'Geregistreerd' ? new Date(reg.status.herregistratieDatum) : null;
}
/** A registration may apply for herregistratie only while active and within the
window before its deadline. A struck-off or suspended registration may not.
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
the reference implementation + unit test; the frontend no longer calls it. */
export function isHerregistratieEligible(
reg: Registration,
today: Date,
windowMonths = 12,
): boolean {
const deadline = herregistratieDeadline(reg);
if (!deadline) return false;
const windowStart = new Date(deadline);
windowStart.setMonth(windowStart.getMonth() - windowMonths);
return today >= windowStart;
}
/** Invariant check used in tests/demos: a non-active status must not carry a
herregistratie date. The union already enforces this structurally; this is
the runtime statement of the same rule. */
export function isStatusConsistent(status: RegistrationStatus): boolean {
return status.tag === 'Geregistreerd' ? typeof status.herregistratieDatum === 'string' : true;
}
-2
View File
@@ -58,8 +58,6 @@ cd backend && dotnet test # rule unit tests + endpoint integration tests
| GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback |
| GET | `/api/intake/policy` | scholing threshold (config value) |
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) / 400 (incomplete scholing answer) |
Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request
carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it
@@ -76,12 +76,6 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
// stay on the client). ponytail: a real submit would carry the full application.
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
// AanvullendeScholing/ScholingPunten (WP-69): the wizard's scholing answer, re-validated
// server-side as the authority by IntakePolicy.RejectIncompleteScholing. Named
// ScholingPunten (not Punten) — the sibling SubmitApplicationRequest is shared by all three
// wizard types and the herregistratie wizard has its own unrelated `punten`.
public sealed record IntakeRequest(int Uren, bool? AanvullendeScholing = null, int? ScholingPunten = null);
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Telefoon);
// Authz/PII-reveal audit row (WP-41) — data-minimised, no PII (see AuthzAuditEntry).
@@ -125,8 +119,8 @@ public sealed record DraftSyncRequest(
IReadOnlyList<string>? DocumentIds = null);
// Submit carries only the fields the server re-validates per wizard type.
// AanvullendeScholing/ScholingPunten (WP-69) — see IntakeRequest; intake-typed aanvragen
// only (gated by IntakePolicy.RejectIncompleteScholing's caller), null for the others.
// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by
// IntakePolicy.RejectIncompleteScholing's caller), null for the others.
public sealed record SubmitApplicationRequest(
string? DiplomaHerkomst = null, int? Uren = null,
IReadOnlyList<DocumentRefDto>? Documents = null,
@@ -1,3 +1,4 @@
using System.Text.Json;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Diplomas;
@@ -12,12 +13,13 @@ public static class Mappers
{
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => s switch
{
RegistrationStatus.Geregistreerd g => new(s.Tag.ToString(), HerregistratieDatum: D(g.HerregistratieDatum)),
RegistrationStatus.Geschorst g => new(s.Tag.ToString(), GeschorstTot: D(g.GeschorstTot), Reden: g.Reden),
RegistrationStatus.Doorgehaald d => new(s.Tag.ToString(), DoorgehaaldOp: D(d.DoorgehaaldOp), Reden: d.Reden),
_ => throw new ArgumentOutOfRangeException(nameof(s), s, "Unknown RegistrationStatus variant"),
};
public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
@@ -45,19 +47,33 @@ public static class Mappers
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
// Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line
// projection of that domain method onto the wire DTO (WP-68 F3).
// Aanvraag status is COMPUTED ON READ (see the StatusAt extension, Data/AanvraagMapper.cs) —
// this is now a one-line projection of that onto the wire DTO (WP-68 F3, WP-73).
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
/// <summary>SubmittedAt only exists once Submitted/Decided (WP-73) — null for a Concept,
/// same as the wire DTO's own nullable field.</summary>
private static string? SubmittedAtOf(Aanvraag a) => a switch
{
Aanvraag.Concept => null,
Aanvraag.Submitted s => s.SubmittedAt.ToString("o"),
Aanvraag.Decided d => d.SubmittedAt.ToString("o"),
_ => null,
};
/// <summary>Draft only exists pre-submission (WP-73) — null once Submitted/Decided (nothing
/// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary>
private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null;
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
/// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null).
public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
a.ToSummaryDto(now) with { Owner = a.Owner };
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
}
@@ -0,0 +1,206 @@
using BigRegister.Domain.Applications;
namespace BigRegister.Api.Data;
/// <summary>
/// The two-way seam between <see cref="AanvraagEntity"/> (the EF-mapped persistence row —
/// mutable, no invariants of its own, exactly the shape SQLite needs) and <see cref="Aanvraag"/>
/// (the closed Concept/Submitted/Decided domain union, WP-73). <see cref="ToDomain"/> is the
/// read half: it reconstructs whichever variant a row's stored fields describe, going through
/// that variant's own constructor/required members, so a row that doesn't actually describe a
/// legal aanvraag throws here rather than downstream. <see cref="ApplyTo"/>/<see cref="ToEntity"/>
/// are the write half, used by <see cref="ApplicationStore"/>'s writers (and test fixtures, e.g.
/// <c>Builders/AanvraagBuilder.cs</c>) to flush a freshly-constructed domain value onto a row
/// before <c>SaveChanges</c>.
/// </summary>
public static class AanvraagMapper
{
public static Aanvraag ToDomain(this AanvraagEntity row)
{
if (!row.Submitted)
return new Aanvraag.Concept(row.StepIndex, row.StepCount)
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = row.UpdatedAt,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Draft = row.Draft,
};
var referentie = row.Referentie
?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no Referentie.");
var submittedAt = row.SubmittedAt
?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no SubmittedAt.");
// Reden wins over BesluitStatus — matches the pre-WP-73 StatusAt's own priority. In
// practice a row never carries both (BeoordelingRules.CanDecide already refuses a besluit
// once Reden's auto-reject makes the projected status Afgewezen), but if it somehow did,
// the auto-reject at submission time is authoritative.
if (row.Reden is null && row.BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = row.UpdatedAt,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
},
Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = row.UpdatedAt,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
Toelichting = row.BesluitToelichting
?? throw new InvalidOperationException($"Afgewezen aanvraag {row.Id} has no toelichting."),
},
Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = row.UpdatedAt,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
Toelichting = row.BesluitToelichting
?? throw new InvalidOperationException($"MeerInfoGevraagd aanvraag {row.Id} has no toelichting."),
},
_ => throw new InvalidOperationException($"Unknown besluit {besluit}."),
};
return new Aanvraag.Submitted
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = row.UpdatedAt,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
AutoApprovable = row.AutoApprovable,
Reden = row.Reden,
};
}
/// <summary>Flushes a domain value onto an already-tracked row — everything but identity
/// (Id/Type/Owner) and CreatedAt, which never change once a row exists. Used by
/// <see cref="ApplicationStore"/>'s SyncDraft/Submit/RecordBesluit, each of which already
/// <c>Find()</c>ed the row this applies to.</summary>
public static void ApplyTo(this Aanvraag a, AanvraagEntity row)
{
row.DocumentIds = a.DocumentIds.ToList();
row.UpdatedAt = a.UpdatedAt;
row.ZaakUrl = a.ZaakUrl;
row.ZgwError = a.ZgwError;
switch (a)
{
case Aanvraag.Concept c:
row.Draft = c.Draft;
row.StepIndex = c.StepIndex;
row.StepCount = c.StepCount;
row.Submitted = false;
row.Referentie = null;
row.SubmittedAt = null;
row.AutoApprovable = false;
row.Reden = null;
row.BesluitStatus = null;
row.BesluitToelichting = null;
break;
case Aanvraag.Submitted s:
// Submitted ⇒ !Draft (WP-73's Draft decision) — nothing reads a submitted aanvraag's
// draft (registratie/application/draft-sync.ts only ever resumes a still-Concept
// wizard), so this is now actually true rather than the aspirational doc-comment it
// used to be.
row.Draft = null;
row.Submitted = true;
row.Referentie = s.Referentie;
row.SubmittedAt = s.SubmittedAt;
row.AutoApprovable = s.AutoApprovable;
row.Reden = s.Reden;
row.BesluitStatus = null;
row.BesluitToelichting = null;
break;
case Aanvraag.Decided d:
row.Draft = null;
row.Submitted = true;
row.Referentie = d.Referentie;
row.SubmittedAt = d.SubmittedAt;
// AutoApprovable/Reden are left as whatever the row already carries from its earlier
// Submitted stage: Decided doesn't model them (StatusAt never consults them once a
// besluit is recorded — its Decided branches are checked first), and a fresh row built
// straight from a Decided fixture with no prior Submitted stage (see ToEntity) simply
// keeps their type defaults (false/null), which is equally harmless for the same reason.
row.BesluitStatus = d switch
{
Aanvraag.Decided.Goedgekeurd => Besluit.Goedkeuren,
Aanvraag.Decided.Afgewezen => Besluit.Afwijzen,
Aanvraag.Decided.MeerInfoGevraagd => Besluit.MeerInfoOpvragen,
_ => throw new InvalidOperationException($"Unknown Decided variant {d.GetType().Name}."),
};
row.BesluitToelichting = d switch
{
Aanvraag.Decided.Goedgekeurd => null,
Aanvraag.Decided.Afgewezen af => af.Toelichting,
Aanvraag.Decided.MeerInfoGevraagd m => m.Toelichting,
_ => throw new InvalidOperationException($"Unknown Decided variant {d.GetType().Name}."),
};
break;
}
}
/// <summary>A brand-new row for a domain value that has no existing row yet — test fixtures'
/// <c>db.Applications.Add(...)</c> (see <c>Acceptance/BesluitLifecycleTests.cs</c>,
/// <c>Acceptance/IntakeSubmissionTests.cs</c>), and (indirectly, via <see cref="ApplyTo"/>)
/// <see cref="ApplicationStore.CreateConcept"/>'s very first insert.</summary>
public static AanvraagEntity ToEntity(this Aanvraag a)
{
var row = new AanvraagEntity { Id = a.Id, Type = a.Type, Owner = a.Owner, CreatedAt = a.CreatedAt };
a.ApplyTo(row);
return row;
}
/// <summary>The status at a point in time (WP-68 F3, WP-73) — pattern matching over the
/// closed <see cref="Aanvraag"/> union, replacing the null-forgiving derefs the old flat
/// mutable row needed (Referentie/SubmittedAt are simply non-nullable on Submitted/Decided
/// now, so there's nothing left to force). A recorded decision wins over the auto-approve
/// computation, matching the pre-WP-73 priority.</summary>
public static AanvraagStatus StatusAt(this Aanvraag a, DateTimeOffset now) => a switch
{
Aanvraag.Concept c => AanvraagStatus.Concept(c.StepIndex, c.StepCount),
Aanvraag.Submitted { Reden: { } reden } s => AanvraagStatus.Afgewezen(s.Referentie, reden),
Aanvraag.Decided.Goedgekeurd g => AanvraagStatus.Goedgekeurd(g.Referentie),
Aanvraag.Decided.Afgewezen af => AanvraagStatus.Afgewezen(af.Referentie, af.Toelichting),
Aanvraag.Decided.MeerInfoGevraagd m => AanvraagStatus.MeerInfoGevraagd(m.Referentie, m.Toelichting),
Aanvraag.Submitted s when s.AutoApprovable && now > s.SubmittedAt + ApplicationStore.ProcessingWindow =>
AanvraagStatus.Goedgekeurd(s.Referentie),
Aanvraag.Submitted s => AanvraagStatus.InBehandeling(s.Referentie, manual: !s.AutoApprovable),
_ => throw new InvalidOperationException($"Unknown Aanvraag variant {a.GetType().Name}."),
};
}
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// EF Core/SQLite persistence for the three stores that used to be static
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
/// in-memory dictionaries (WP-22): <see cref="AanvraagEntity"/>, <see cref="StoredDocument"/>
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
/// JSON text columns rather than redesigned into relational tables — the backend
@@ -21,7 +21,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<AuthzAuditEntry> AuthzAudit => Set<AuthzAuditEntry>();
public DbSet<FeatureFlagEntity> FeatureFlags => Set<FeatureFlagEntity>();
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
public DbSet<AanvraagEntity> Applications => Set<AanvraagEntity>();
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
@@ -43,7 +43,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
modelBuilder.Entity<FeatureFlagEntity>().HasKey(f => f.Key);
modelBuilder.Entity<Aanvraag>(e =>
modelBuilder.Entity<AanvraagEntity>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Draft).HasConversion(DraftConverter);
@@ -6,13 +6,16 @@ using BigRegister.Domain.Submissions;
namespace BigRegister.Api.Data;
/// <summary>
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen
/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see <see cref="StatusAt"/>) so
/// auto-approval is purely a function of stored timestamps — no timers, no jobs.
/// The EF-mapped persistence row for an application (aanvraag) — WP-73 demoted this to
/// exactly that: a flat, mutable bag with no invariants of its own (SQLite needs precisely
/// this shape), never read or written directly outside this file. Everywhere else, production
/// code reads and writes <see cref="Aanvraag"/> (the closed Concept/Submitted/Decided domain
/// union, <c>Domain/Applications/Aanvraag.cs</c>) — <see cref="AanvraagMapper"/>'s
/// <c>ToDomain</c>/<c>ApplyTo</c>/<c>ToEntity</c> is the two-way seam between the two. Status is
/// COMPUTED ON READ (see the <c>StatusAt</c> extension below) so auto-approval is purely a
/// function of stored timestamps — no timers, no jobs.
/// </summary>
public sealed class Aanvraag
public sealed class AanvraagEntity
{
public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake
@@ -53,26 +56,6 @@ public sealed class Aanvraag
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
/// the published status's Reden), optional for Goedkeuren.</summary>
public string? BesluitToelichting { get; set; }
/// <summary>The status at a point in time (WP-68 F3) — moved here from
/// <c>Contracts.Mappers.ToStatusDto</c>, which is now a one-line projection of this. A
/// recorded decision wins over the auto-approve computation below.</summary>
public AanvraagStatus StatusAt(DateTimeOffset now)
{
if (!Submitted) return AanvraagStatus.Concept(StepIndex, StepCount);
if (Reden is not null) return AanvraagStatus.Afgewezen(Referentie!, Reden);
if (BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => AanvraagStatus.Goedgekeurd(Referentie!),
Besluit.Afwijzen => AanvraagStatus.Afgewezen(Referentie!, BesluitToelichting),
Besluit.MeerInfoOpvragen => AanvraagStatus.MeerInfoGevraagd(Referentie!, BesluitToelichting),
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
};
if (AutoApprovable && now > SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return AanvraagStatus.Goedgekeurd(Referentie!);
return AanvraagStatus.InBehandeling(Referentie!, manual: !AutoApprovable);
}
}
/// <summary>
@@ -90,10 +73,13 @@ public static class ApplicationStore
/// Create a Concept for <paramref name="owner"/> — UNLESS one of this
/// <paramref name="type"/> already exists unsubmitted. WP-35: at most one Concept per
/// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort).
/// Race-free: the existence check and the insert share the single write gate. Returns
/// null when a duplicate would be created (the caller maps that to 409 Conflict).
public static Aanvraag? CreateConcept(string type, string owner)
/// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort;
/// this stays procedural here — it's an AGGREGATE-SET rule over every (Owner, Type), not
/// something a single Aanvraag value's own shape could ever encode, and there is no unique
/// index in the schema either). Race-free: the existence check and the insert share the
/// single write gate. Returns null when a duplicate would be created (the caller maps that
/// to 409 Conflict).
public static Aanvraag.Concept? CreateConcept(string type, string owner)
{
var now = DateTimeOffset.UtcNow;
lock (_gate)
@@ -101,10 +87,18 @@ public static class ApplicationStore
using var db = Db.Create();
if (db.Applications.Any(a => a.Owner == owner && a.Type == type && !a.Submitted))
return null;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
db.Applications.Add(a);
var concept = new Aanvraag.Concept(stepIndex: 0, stepCount: 0)
{
Id = Guid.NewGuid().ToString(),
Type = type,
Owner = owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = now,
UpdatedAt = now,
};
db.Applications.Add(concept.ToEntity());
db.SaveChanges();
return a;
return concept;
}
}
@@ -114,7 +108,7 @@ public static class ApplicationStore
{
using var db = Db.Create();
var a = db.Applications.Find(id);
return a is not null && a.Owner == owner ? a : null;
return a is not null && a.Owner == owner ? a.ToDomain() : null;
}
}
@@ -123,7 +117,7 @@ public static class ApplicationStore
lock (_gate)
{
using var db = Db.Create();
return db.Applications.Where(a => a.Owner == owner).ToList();
return db.Applications.Where(a => a.Owner == owner).ToList().Select(a => a.ToDomain()).ToList();
}
}
@@ -134,7 +128,7 @@ public static class ApplicationStore
lock (_gate)
{
using var db = Db.Create();
return db.Applications.Find(id);
return db.Applications.Find(id)?.ToDomain();
}
}
@@ -149,7 +143,7 @@ public static class ApplicationStore
lock (_gate)
{
using var db = Db.Create();
return db.Applications.FirstOrDefault(a => a.Referentie == referentie);
return db.Applications.FirstOrDefault(a => a.Referentie == referentie)?.ToDomain();
}
}
@@ -162,23 +156,34 @@ public static class ApplicationStore
using var db = Db.Create();
// Order client-side: SQLite can't ORDER BY a DateTimeOffset (same constraint the
// rest of the store sidesteps by never sorting in the query).
return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).ToList();
return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).Select(a => a.ToDomain()).ToList();
}
}
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable — the
/// domain reconstruction below is what enforces "0 &lt;= StepIndex &lt;= StepCount"
/// (<see cref="Aanvraag.Concept"/>'s own constructor throws on an out-of-range pair instead
/// of this silently writing one onto the row, the way the pre-WP-73 code did).
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
var row = db.Applications.Find(id);
if (row is null || row.Owner != owner || row.Submitted) return false;
var concept = new Aanvraag.Concept(stepIndex, stepCount)
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = documentIds ?? row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = DateTimeOffset.UtcNow,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Draft = draft.Clone(), // detach from the request's JsonDocument (disposed after the call)
};
concept.ApplyTo(row);
db.SaveChanges();
return true;
}
@@ -226,23 +231,39 @@ public static class ApplicationStore
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
/// if the aanvraag is gone or already submitted (idempotency guard). WP-73: the returned
/// <see cref="Aanvraag.Submitted"/> is constructed with a non-null Referentie/SubmittedAt by
/// its own required members — there is no longer a null-forgiving deref anywhere down the
/// line reading them back (<c>StatusAt</c>, <c>IZaakSource.CreateZaak</c>). Submitting also
/// clears the row's Draft (<see cref="AanvraagMapper.ApplyTo"/>'s Submitted branch) — nothing
/// reads a submitted aanvraag's draft (the FE only ever resumes a still-Concept wizard), so
/// the doc-comment's old "Draft is Concept only" claim is now actually true, not aspirational.
public static Aanvraag.Submitted? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
var row = db.Applications.Find(id);
if (row is null || row.Owner != owner || row.Submitted) return null;
var now = DateTimeOffset.UtcNow;
var submitted = new Aanvraag.Submitted
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = documentIds ?? row.DocumentIds,
CreatedAt = row.CreatedAt,
UpdatedAt = now,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = SubmissionRules.NewReference(),
SubmittedAt = now,
AutoApprovable = autoApprovable,
Reden = reject,
};
submitted.ApplyTo(row);
db.SaveChanges();
return a;
return submitted;
}
}
@@ -282,22 +303,83 @@ public static class ApplicationStore
/// now runs INSIDE this lock, against a status read fresh under the lock, rather than in
/// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's
/// check before either wrote, letting the second silently overwrite a terminal decision.
/// WP-73: <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// require a non-null Toelichting by their own shape — the endpoint already 400s a missing
/// one (<c>BeoordelingRules.RequiresToelichting</c>), and this is the defense-in-depth
/// backstop for any other caller (this method is public, and e.g.
/// <c>Acceptance/BesluitLifecycleTests.cs</c> calls it directly, bypassing the endpoint).
/// </summary>
public static (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
public static (RecordBesluitOutcome Outcome, Aanvraag.Decided? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null) return (RecordBesluitOutcome.NotFound, null);
var current = a.StatusAt(now).Tag;
if (current is null || !BeoordelingRules.CanDecide(current.Value))
var row = db.Applications.Find(id);
if (row is null) return (RecordBesluitOutcome.NotFound, null);
var current = row.ToDomain();
var tag = current.StatusAt(now).Tag;
if (tag is null || !BeoordelingRules.CanDecide(tag.Value))
return (RecordBesluitOutcome.Conflict, null);
a.BesluitStatus = besluit;
a.BesluitToelichting = toelichting;
a.UpdatedAt = DateTimeOffset.UtcNow;
// tag non-null ⇒ current is Submitted or already Decided, never Concept ⇒ Referentie/
// SubmittedAt already exist — carried forward rather than re-derived.
var (referentie, submittedAt) = current switch
{
Aanvraag.Submitted s => (s.Referentie, s.SubmittedAt),
Aanvraag.Decided d => (d.Referentie, d.SubmittedAt),
_ => throw new InvalidOperationException($"Aanvraag {id} has a decidable status but is not submitted."),
};
Aanvraag.Decided decided = besluit switch
{
Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = current.DocumentIds,
CreatedAt = current.CreatedAt,
UpdatedAt = now,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
},
Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = current.DocumentIds,
CreatedAt = current.CreatedAt,
UpdatedAt = now,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
Toelichting = toelichting ?? throw new InvalidOperationException("Afwijzen requires a toelichting."),
},
Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd
{
Id = row.Id,
Type = row.Type,
Owner = row.Owner,
DocumentIds = current.DocumentIds,
CreatedAt = current.CreatedAt,
UpdatedAt = now,
ZaakUrl = row.ZaakUrl,
ZgwError = row.ZgwError,
Referentie = referentie,
SubmittedAt = submittedAt,
Toelichting = toelichting ?? throw new InvalidOperationException("MeerInfoOpvragen requires a toelichting."),
},
_ => throw new InvalidOperationException($"Unknown besluit {besluit}."),
};
decided.ApplyTo(row); // also sets row.UpdatedAt = decided.UpdatedAt (= now, above)
db.SaveChanges();
return (RecordBesluitOutcome.Ok, a);
return (RecordBesluitOutcome.Ok, decided);
}
}
}
+13 -10
View File
@@ -33,17 +33,20 @@ public interface IZaakSource
/// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
/// integration side-effect, and (Referentie, Status) is what the submit endpoint hands back
/// to the FE (ADR-0001: route the create through the existing submit response DTO, don't add
/// a second one). The local source is a pure passthrough of the already-computed local
/// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak
/// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL
/// so the endpoint can persist it (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
/// to later link documents to this zaak). <paramref name="caller"/> (WP-53) is the acting
/// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran, hence the
/// <see cref="Aanvraag.Submitted"/> parameter type — WP-73: a freshly submitted aanvraag
/// always has a Referentie, so neither implementation needs a null-forgiving deref for it
/// any more) — this is the integration side-effect, and (Referentie, Status) is what the
/// submit endpoint hands back to the FE (ADR-0001: route the create through the existing
/// submit response DTO, don't add a second one). The local source is a pure passthrough of
/// the already-computed local reference/status (ZaakUrl null — nothing to persist); the
/// OpenZaak source creates a Zaak (+ status + rol) and maps the result back into the same
/// shape, returning the zaak's URL so the endpoint can persist it
/// (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it to later link documents to this
/// zaak). <paramref name="caller"/> (WP-53) is the acting citizen — the ZGW JWT's audit
/// claims reflect them, not a static config identity.
/// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller);
/// <summary>
/// Extend a behandelaar's already-locally-recorded decision (WP-65b's
@@ -24,8 +24,8 @@ public sealed class LocalZaakSource : IZaakSource
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie, aanvraag.ToStatusDto(now), null);
/// <summary>No external zaak to update — the recorded decision already IS the record of
/// truth locally (WP-66). Zero behaviour change.</summary>
+1 -1
View File
@@ -16,7 +16,7 @@ public static class SeedData
Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
Status: new RegistrationStatus.Geregistreerd(HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries",
@@ -0,0 +1,103 @@
using System.Text.Json;
namespace BigRegister.Domain.Applications;
/// <summary>
/// The aanvraag lifecycle as a closed union (WP-73): <see cref="Concept"/> (the pre-submission
/// wizard draft) → <see cref="Submitted"/> (awaiting a behandelaar's decision, or already
/// auto-rejected at submission time — see <see cref="Submitted.Reden"/>) → <see cref="Decided"/>
/// (a behandelaar's outcome recorded). Each variant carries only the fields that make sense for
/// it; the private base constructor closes the hierarchy to the nested sealed records below, so
/// a caller can never construct a fourth variant, a <see cref="Decided"/> with no referentie, or
/// an Afwijzen/MeerInfoGevraagd with no toelichting — each is a compile error (a missing
/// `required` member, CS9035), not a runtime null-check the way the old flat, mutable
/// <c>Aanvraag</c> needed one.
///
/// <see cref="Api.Data.AanvraagEntity"/> is the EF-mapped persistence row this maps to/from
/// (<c>Api.Data.AanvraagMapper</c>'s <c>ToDomain</c>/<c>ApplyTo</c>/<c>ToEntity</c>) — it stays a
/// flat, mutable bag with no invariants of its own (SQLite needs exactly that shape); this type
/// is what production code actually reads and writes everywhere else. The wire-facing,
/// point-in-time <see cref="AanvraagStatus"/> a screen renders is a further, time-dependent
/// projection (<c>StatusAt</c>, in <c>Api.Data</c>) — the auto-approval window is a function of
/// wall-clock time, not of this stored shape, so it stays a derived read rather than a fourth
/// member of this union.
/// </summary>
public abstract record Aanvraag
{
public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; }
public required IReadOnlyList<string> DocumentIds { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under
/// the local source, or before a zaak has been registered at all.</summary>
public string? ZaakUrl { get; init; }
/// <summary>WP-60: non-null means the ZGW side of this aanvraag's last write did not
/// complete — see <c>Api.Data.ApplicationStore.SetZgwError</c>.</summary>
public string? ZgwError { get; init; }
private Aanvraag() { }
/// <summary>Pre-submission wizard draft.</summary>
public sealed record Concept : Aanvraag
{
public JsonElement? Draft { get; init; }
public int StepIndex { get; }
public int StepCount { get; }
/// <summary>0 &lt;= <paramref name="stepIndex"/> &lt;= <paramref name="stepCount"/> — the
/// non-strict upper bound, not the strict "&lt;" a wizard's own step cursor uses, because
/// <c>ApplicationStore.CreateConcept</c>'s freshly-created row is (StepIndex: 0, StepCount:
/// 0) before the wizard's first draft sync ever runs, and that has to stay constructible.
/// </summary>
public Concept(int stepIndex, int stepCount)
{
if (stepIndex < 0 || stepCount < 0 || stepIndex > stepCount)
throw new ArgumentOutOfRangeException(
nameof(stepIndex), stepIndex, $"StepIndex must be within [0, StepCount ({stepCount})].");
StepIndex = stepIndex;
StepCount = stepCount;
}
}
/// <summary>Submitted, no behandelaar decision recorded yet. <see cref="Reden"/> non-null
/// means <c>SubmissionRules</c> rejected it automatically at submission time (e.g. a manually
/// entered diploma) — terminal in practice (<c>BeoordelingRules.CanDecide</c> refuses a
/// besluit once the projected status is already Afgewezen) but structurally still "no besluit
/// was ever recorded", hence it lives here rather than in <see cref="Decided"/>.</summary>
public sealed record Submitted : Aanvraag
{
public required string Referentie { get; init; }
public required DateTimeOffset SubmittedAt { get; init; }
public required bool AutoApprovable { get; init; }
public string? Reden { get; init; }
}
/// <summary>A behandelaar's decision (WP-65b/68) — closed by besluit: only
/// <see cref="Afgewezen"/>/<see cref="MeerInfoGevraagd"/> require a toelichting
/// (<c>BeoordelingRules.RequiresToelichting</c>'s rule, now also a type, not just an endpoint
/// check) — omitting it is a compile error, not merely a 400 the type happens to also let
/// slip through at runtime.</summary>
public abstract record Decided : Aanvraag
{
public required string Referentie { get; init; }
public required DateTimeOffset SubmittedAt { get; init; }
private Decided() { }
public sealed record Goedgekeurd : Decided;
public sealed record Afgewezen : Decided
{
public required string Toelichting { get; init; }
}
public sealed record MeerInfoGevraagd : Decided
{
public required string Toelichting { get; init; }
}
}
}
@@ -5,10 +5,10 @@ namespace BigRegister.Domain.Intake;
/// scholing question is required. The frontend receives this value
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the
/// backend re-validating it as the authority on submit (WP-69) — both
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) and the legacy
/// <c>POST /intakes</c> call it before writing anything, and a violation 400s
/// (<c>ProblemDetails</c>), never silently accepts an incomplete answer.
/// backend re-validating it as the authority on submit (WP-69) —
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) calls it before
/// writing anything, and a violation 400s (<c>ProblemDetails</c>), never silently accepts
/// an incomplete answer.
/// </summary>
public static class IntakePolicy
{
@@ -11,7 +11,7 @@ public static class HerregistratieRule
public const int WindowMonths = 12;
public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
reg.Status is RegistrationStatus.Geregistreerd g ? g.HerregistratieDatum : null;
public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths)
@@ -25,8 +25,4 @@ public static class HerregistratieRule
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
}
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
}
@@ -9,15 +9,37 @@ public enum StatusTag
}
/// <summary>
/// Status as a flat record: only <see cref="StatusTag.Geregistreerd"/> carries a
/// herregistratie deadline. The frontend mirrors this as a discriminated union.
/// Status as a closed union: each variant carries exactly the data that makes sense for it
/// (WP-73). Only <see cref="Geregistreerd"/> carries a herregistratie deadline; only
/// <see cref="Geschorst"/> and <see cref="Doorgehaald"/> carry a reden — and there it is
/// required, not nullable (the old flat record left <c>Reden</c> nullable on every tag,
/// diverging from the frontend union, which has always required it on those two variants —
/// see <c>registratie/domain/registration.ts</c>). The private base constructor closes the
/// hierarchy: only the three nested sealed records below can ever inherit from
/// <see cref="RegistrationStatus"/>, so a caller can never construct e.g. a
/// <see cref="Geschorst"/> with a herregistratie date, or a fourth variant.
/// </summary>
public sealed record RegistrationStatus(
StatusTag Tag,
DateOnly? HerregistratieDatum = null,
DateOnly? GeschorstTot = null,
string? Reden = null,
DateOnly? DoorgehaaldOp = null);
public abstract record RegistrationStatus
{
public abstract StatusTag Tag { get; }
private RegistrationStatus() { }
public sealed record Geregistreerd(DateOnly HerregistratieDatum) : RegistrationStatus
{
public override StatusTag Tag => StatusTag.Geregistreerd;
}
public sealed record Geschorst(DateOnly GeschorstTot, string Reden) : RegistrationStatus
{
public override StatusTag Tag => StatusTag.Geschorst;
}
public sealed record Doorgehaald(DateOnly DoorgehaaldOp, string Reden) : RegistrationStatus
{
public override StatusTag Tag => StatusTag.Doorgehaald;
}
}
public sealed record Registration(
string BigNummer,
@@ -21,14 +21,21 @@ public static class SubmissionRules
private static readonly Regex PhonePattern =
new(@"^0\d{9}$", RegexOptions.Compiled);
private static readonly Regex StrippedChars =
new(@"[\s\-()]", RegexOptions.Compiled);
private static readonly Regex LeadingCountryCode =
new(@"^\+31", RegexOptions.Compiled);
// RULE: a contact change needs a well-formed Dutch phone number (10 digits, leading
// 0, formatting stripped). The BRP address is authoritative and cannot be changed
// here (WP-34), so only the phone is submitted. The server re-validates format
// authoritatively (the FE check is UX-only).
// authoritatively (the FE check is UX-only) — and must strip the SAME formatting the
// FE's parseTelefoonnummer does (whitespace/dashes/parens, a leading +31 → 0; WP-75),
// or the two sides disagree on what's a valid number.
public static string? RejectPhoneChange(string telefoon)
{
var digits = (telefoon ?? "").Trim().Replace(" ", "").Replace("-", "");
var stripped = StrippedChars.Replace((telefoon ?? "").Trim(), "");
var digits = LeadingCountryCode.Replace(stripped, "0");
if (!PhonePattern.IsMatch(digits)) return "Voer een geldig telefoonnummer in, bijv. 0612345678.";
return null;
}
+5 -22
View File
@@ -189,24 +189,6 @@ api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
{
// WP-69: completeness check outside Submit(...) — deliberately not folded into `reject`,
// so this 400 is never cached in IdempotencyStore the way a 422 rejection would be.
var reject = SubmissionRules.RejectZeroUren(req.Uren);
if (reject is null && IntakePolicy.RejectIncompleteScholing(req.Uren, req.AanvullendeScholing, req.ScholingPunten) is { } incomplete)
return Results.Problem(detail: incomplete, statusCode: StatusCodes.Status400BadRequest);
return Submit(ctx, "intake", reject);
})
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon)))
.Produces<ReferentieResponse>()
@@ -344,7 +326,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
{
var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
if (a is null) return Results.NotFound();
if (a.Submitted)
if (a is not Aanvraag.Concept)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, ctx.Zorgverlener().Bsn);
return Results.NoContent();
@@ -359,7 +341,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
{
var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
if (existing is null) return Results.NotFound();
if (existing.Submitted)
if (existing is not Aanvraag.Concept)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
@@ -404,7 +386,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
// failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged
// one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught
// separately so a create-zaak failure doesn't also skip the (still-local) document link.
var referentie = submitted.Referentie!;
var referentie = submitted.Referentie;
var status = submitted.ToStatusDto(DateTimeOffset.UtcNow);
string? zaakUrl = null;
try
@@ -525,7 +507,8 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
}
catch (Exception ex)
{
RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex);
// WP-73: Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed.
RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex);
}
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
@@ -94,10 +94,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// succeeded. The caller (Program.cs's submit endpoint) catches this and records it as a
/// flagged divergence (Aanvraag.ZgwError) instead of letting it fail (or diverge) silently —
/// see openzaak-integration.md's "Write resilience" section.
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) =>
CreateZaakAsync(aanvraag, now, caller).GetAwaiter().GetResult();
private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller)
private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller)
{
if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
throw new InvalidOperationException(
@@ -108,8 +108,9 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
Bronorganisatie: options.Bronorganisatie,
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
Identificatie: aanvraag.Referentie
?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")), caller);
// WP-73: Aanvraag.Submitted's Referentie is a required, non-nullable member — a
// just-submitted aanvraag always has one, so there is nothing left to null-check here.
Identificatie: aanvraag.Referentie), caller);
var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen",
-124
View File
@@ -249,94 +249,6 @@
}
}
},
"/api/v1/herregistraties": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HerregistratieRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/intakes": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IntakeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/change-requests": {
"post": {
"tags": [
@@ -2149,23 +2061,6 @@
},
"additionalProperties": false
},
"HerregistratieRequest": {
"type": "object",
"properties": {
"uren": {
"type": "integer",
"format": "int32"
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"IntakePolicyDto": {
"type": "object",
"properties": {
@@ -2176,25 +2071,6 @@
},
"additionalProperties": false
},
"IntakeRequest": {
"type": "object",
"properties": {
"uren": {
"type": "integer",
"format": "int32"
},
"aanvullendeScholing": {
"type": "boolean",
"nullable": true
},
"scholingPunten": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
},
"LetterBlockDto": {
"type": "object",
"properties": {
@@ -25,7 +25,7 @@ public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFi
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.Applications.Add(aanvraag.ToEntity());
db.SaveChanges();
}
@@ -74,7 +74,7 @@ public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFi
// When the status is read long after the auto-approve window has passed — the instant an
// undecided auto-approvable case of the same shape WOULD read Goedgekeurd (see
// ApplicationTests.AutoApprovable_flips_to_goedgekeurd_after_the_window)...
var longAfterTheWindow = aanvraag.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var longAfterTheWindow = aanvraag.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(longAfterTheWindow);
// Then the recorded decision still wins — Afgewezen, never Goedgekeurd.
@@ -8,13 +8,13 @@ using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance;
/// <summary>
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over both live HTTP
/// paths — <c>POST /applications/{id}/submit</c> (the wizard's real path) and the legacy
/// <c>POST /intakes</c> (dead from the UI, still a live crafted-POST surface). Built through
/// the <see cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/>
/// rather than the full wizard/upload dance — the builder's default owner IS
/// <see cref="BigRegister.Api.Domain.Authorization.StubIdentityProvider"/>'s default caller,
/// so no header juggling.
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over
/// <c>POST /applications/{id}/submit</c> (the wizard's real path — WP-72 deleted the legacy
/// <c>POST /intakes</c> endpoint this once also covered). Built through the <see
/// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather
/// than the full wizard/upload dance — the builder's default owner IS <see
/// cref="BigRegister.Api.Domain.Authorization.StubIdentityProvider"/>'s default caller, so
/// no header juggling.
/// </summary>
public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
@@ -23,7 +23,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.Applications.Add(aanvraag.ToEntity());
db.SaveChanges();
}
@@ -44,8 +44,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
// ...and the aanvraag is left a retryable Concept, never marked Submitted.
var stillConcept = ApplicationStore.GetAny(aanvraag.Id)!;
Assert.False(stillConcept.Submitted);
Assert.IsType<Aanvraag.Concept>(ApplicationStore.GetAny(aanvraag.Id));
}
[Fact]
@@ -121,16 +120,4 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
[Fact]
public async Task Legacy_intakes_endpoint_enforces_it_too()
{
// Given no aanvraag needed — the legacy endpoint mints its own reference.
// When a crafted POST hits the dead-from-the-UI /intakes endpoint below threshold,
// with no scholing answer...
var res = await _client.PostAsJsonAsync("/api/v1/intakes", new { uren = 500 });
// Then it is rejected too — the crafted-POST surface this WP closes.
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
}
@@ -0,0 +1,54 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
namespace BigRegister.Tests.Acceptance;
/// <summary>
/// Contract test for the FE/BE seam on phone-number stripping (WP-75). Both sides share
/// the same format regex (<c>^0\d{9}$</c>) but, until this test, diverged on what they
/// strip before checking it: the FE's <c>parseTelefoonnummer</c>
/// (registratie/domain/value-objects/telefoonnummer.ts) also drops parentheses and maps a
/// leading <c>+31</c> to a leading <c>0</c>; <see cref="BigRegister.Domain.Submissions.SubmissionRules.RejectPhoneChange"/>
/// used to strip only spaces and dashes. This was latent, not live — the FE always sends
/// its already-normalised value over the wire — but a crafted/future caller posting a raw,
/// FE-valid number would hit a backend that disagrees with the FE about what's valid. The
/// backend is the authority (ADR-0001), so it must agree with the FE on every FE-valid input.
/// </summary>
public class PhoneFormatContractTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private Task<HttpResponseMessage> PostChangeRequest(string telefoon) =>
_client.PostAsJsonAsync("/api/v1/change-requests", new ChangeRequestRequest(telefoon));
[Fact]
public async Task A_leading_plus31_is_accepted_like_the_frontends_normalised_form()
{
// Given a phone number in international form — parseTelefoonnummer maps a leading
// "+31" to "0" and accepts it (it becomes "0612345678", 10 digits starting 0).
// When it is posted to the backend exactly as the user typed it (not FE-normalised)...
var res = await PostChangeRequest("+31612345678");
// Then the backend must agree it's valid, not reject it as malformed.
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<ReferentieResponse>())!;
Assert.NotNull(body.Referentie);
}
[Fact]
public async Task Parentheses_around_the_area_code_are_accepted_like_the_frontend()
{
// Given a phone number with the area code in parentheses — parseTelefoonnummer strips
// "()" along with spaces/dashes and accepts it (it becomes "0612345678").
// When it is posted to the backend exactly as the user typed it...
var res = await PostChangeRequest("(06) 12345678");
// Then the backend must agree it's valid, not reject it as malformed.
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<ReferentieResponse>())!;
Assert.NotNull(body.Referentie);
}
}
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Tests.Builders;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
@@ -227,25 +228,14 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new()
{
Id = "x",
Type = "registratie",
Owner = "test",
Submitted = true,
AutoApprovable = autoApprovable,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
private static Aanvraag.Submitted Accepted(bool autoApprovable) =>
Given.Concept(type: "registratie", owner: "test").Submitted(autoApprovable).Build();
[Fact]
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
{
var a = Accepted(autoApprovable: true);
Assert.NotNull(a.SubmittedAt);
var t0 = a.SubmittedAt.Value;
var t0 = a.SubmittedAt;
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
}
@@ -254,8 +244,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public void Manual_case_never_auto_advances()
{
var a = Accepted(autoApprovable: false);
Assert.NotNull(a.SubmittedAt);
var far = a.SubmittedAt.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var far = a.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = a.ToStatusDto(far);
Assert.Equal("InBehandeling", status.Tag);
Assert.True(status.Manual);
@@ -26,7 +26,7 @@ file sealed class IdMismatchZaakSource : IZaakSource
inner.ListMyCases(caller, now).Select(Rekey).ToList();
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(
Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller);
Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller);
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller);
@@ -1,7 +1,6 @@
using System.Threading;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
namespace BigRegister.Tests.Builders;
@@ -15,18 +14,16 @@ public static class TestIdentities
}
/// <summary>
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70). "Build test data through the
/// same door production code uses" — a Concept can only ever become Submitted, and only a
/// Submitted aanvraag can be Decided, so the compiler refuses a fixture built through an illegal
/// path (e.g. deciding a still-Concept aanvraag) instead of that being a runtime assertion nobody
/// wrote. Start at <see cref="Given.Concept"/>.
///
/// ponytail: <see cref="Aanvraag"/> itself stays exactly what it always was — a mutable,
/// EF-backed bag with no invariants of its own (that's Data/ApplicationStore.cs's job in
/// production, via its own lock + <see cref="BeoordelingRules"/> checks). This builder does not
/// refactor it into an immutable aggregate; it's the one enforced DOOR through which TEST code
/// builds one, so the invariants a real request path enforces don't quietly go missing from a
/// fixture assembled by hand.
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70; simplified at WP-73). "Build
/// test data through the same door production code uses" — <see cref="Aanvraag"/> itself is now
/// the closed Concept/Submitted/Decided union WP-73 introduced, so this builder no longer needs
/// to mirror production's guards (step-index bounds, "Afwijzen needs a toelichting") by hand —
/// it just calls the real nested constructors/required members, which enforce them. A call that
/// would build an illegal Aanvraag (e.g. deciding a still-Concept aanvraag, or an Afwijzen with
/// no toelichting) is refused the same way production refuses it: a still-Concept aanvraag has
/// no <c>.Decided(...)</c> to call in the first place, and a missing toelichting is a runtime
/// guard identical to <c>ApplicationStore.RecordBesluit</c>'s own. Start at
/// <see cref="Given.Concept"/>.
/// </summary>
public static class Given
{
@@ -52,60 +49,50 @@ public sealed class ConceptAanvraag
}
/// The wizard's current position — step <paramref name="index"/> of <paramref name="of"/>.
/// Guarded the same way a real cursor is (`STEPS[Math.min(cursor, STEPS.length - 1)]` on the
/// frontend): <paramref name="of"/> must be at least 1, and <paramref name="index"/> must fall
/// within <c>[0, of)</c> — <c>AtStep(9, 2)</c> is not a position any real wizard can reach, so
/// the builder refuses it instead of silently building an impossible fixture.
/// Bounds are <see cref="Aanvraag.Concept"/>'s OWN constructor's to enforce, not this
/// builder's — an out-of-range pair fails at <see cref="Build"/>, the same
/// <see cref="ArgumentOutOfRangeException"/> production throws, not a guard restated here.
public ConceptAanvraag AtStep(int index, int of)
{
if (of < 1)
throw new ArgumentOutOfRangeException(nameof(of), of, "Step count must be at least 1.");
if (index < 0 || index >= of)
throw new ArgumentOutOfRangeException(nameof(index), index, $"Step index must be within [0, {of}).");
_stepIndex = index;
_stepCount = of;
return this;
}
/// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors
/// <c>ApplicationStore.Submit</c>), so <c>Aanvraag.StatusAt</c>'s <c>Referentie!</c> is honest
/// for every fixture built this way, never a null-ref waiting to happen.
public SubmittedAanvraag Submitted(bool autoApprovable = false) =>
new(_type, _owner, _stepIndex, _stepCount, autoApprovable);
/// <c>ApplicationStore.Submit</c>), so a fixture built this way can never hit the
/// null-forgiving derefs the pre-WP-73 flat Aanvraag needed (there's nothing to force any
/// more: both are required, non-null members of <see cref="Aanvraag.Submitted"/>).
public SubmittedAanvraag Submitted(bool autoApprovable = false) => new(_type, _owner, autoApprovable);
public Aanvraag Build() => new()
public Aanvraag.Concept Build() => new(_stepIndex, _stepCount)
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
DocumentIds = Array.Empty<string>(),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>A submitted aanvraag, open for a behandelaar's decision. The only next step is
/// <see cref="Decided"/> — there is no way back to <c>ConceptAanvraag</c>.</summary>
/// <see cref="Decided"/> — there is no way back to <see cref="ConceptAanvraag"/>.</summary>
public sealed class SubmittedAanvraag
{
private static int _referentieSeq;
private readonly string _type;
private readonly string _owner;
private readonly int _stepIndex;
private readonly int _stepCount;
private readonly bool _autoApprovable;
private readonly string _referentie;
private readonly DateTimeOffset _submittedAt;
private string? _zaakUrl;
internal SubmittedAanvraag(string type, string owner, int stepIndex, int stepCount, bool autoApprovable)
internal SubmittedAanvraag(string type, string owner, bool autoApprovable)
{
_type = type;
_owner = owner;
_stepIndex = stepIndex;
_stepCount = stepCount;
_autoApprovable = autoApprovable;
// A plausible reference in SubmissionRules.NewReference's shape ("BIG-2026-" + a number) —
// sequential (not random) so a fixture's value is reproducible across a test run.
@@ -114,67 +101,97 @@ public sealed class SubmittedAanvraag
}
/// <summary>Registers this aanvraag's already-known OpenZaak zaak URL — mirrors
/// <see cref="Api.Data.ApplicationStore.SetZaakUrl"/>, the one production writer of this
/// field, so a fixture that needs a pre-existing zaak doesn't reach past <c>Build()</c> to
/// mutate the result by hand.</summary>
/// <see cref="ApplicationStore.SetZaakUrl"/>, the one production writer of this field, so a
/// fixture that needs a pre-existing zaak doesn't reach past <c>Build()</c> to mutate the
/// result by hand.</summary>
public SubmittedAanvraag WithZaakUrl(string zaakUrl)
{
_zaakUrl = zaakUrl;
return this;
}
/// <summary>Records a behandelaar's decision — reusing <see cref="BeoordelingRules.RequiresToelichting"/>,
/// the SAME rule production's besluit endpoint runs, rather than restating it here where it
/// could quietly drift. Throws <see cref="ArgumentException"/> for an Afwijzen/MeerInfoOpvragen
/// with a null/blank <paramref name="toelichting"/> — exactly what that endpoint rejects with
/// a 400, just caught here at fixture-build time instead.</summary>
public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null)
/// <summary>Records a behandelaar's decision. Unlike the pre-WP-73 builder, there is no
/// hand-written toelichting guard mirroring <c>BeoordelingRules.RequiresToelichting</c> any
/// more — <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// simply have a `required string Toelichting` member; the null-coalescing throw below is the
/// one place a null has to turn into an exception (this method's own parameter is still the
/// nullable <c>string?</c> a wire request would carry), same failure production's own
/// <c>ApplicationStore.RecordBesluit</c> raises for the identical input.</summary>
public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null) => new(BuildDecided(besluit, toelichting));
private Aanvraag.Decided BuildDecided(Besluit besluit, string? toelichting)
{
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(toelichting))
throw new ArgumentException($"{besluit} requires a toelichting.", nameof(toelichting));
return new DecidedAanvraag(this, besluit, toelichting);
var (id, createdAt) = (Guid.NewGuid().ToString(), _submittedAt);
return besluit switch
{
Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd
{
Id = id,
Type = _type,
Owner = _owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = createdAt,
UpdatedAt = createdAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
},
Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen
{
Id = id,
Type = _type,
Owner = _owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = createdAt,
UpdatedAt = createdAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
Toelichting = toelichting ?? throw new ArgumentException("Afwijzen requires a toelichting.", nameof(toelichting)),
},
Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd
{
Id = id,
Type = _type,
Owner = _owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = createdAt,
UpdatedAt = createdAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
Toelichting = toelichting ?? throw new ArgumentException("MeerInfoOpvragen requires a toelichting.", nameof(toelichting)),
},
_ => throw new ArgumentOutOfRangeException(nameof(besluit), besluit, "Unknown besluit."),
};
}
public Aanvraag Build() => new()
public Aanvraag.Submitted Build() => new()
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
Submitted = true,
Referentie = _referentie,
AutoApprovable = _autoApprovable,
SubmittedAt = _submittedAt,
DocumentIds = Array.Empty<string>(),
CreatedAt = _submittedAt,
UpdatedAt = _submittedAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
AutoApprovable = _autoApprovable,
};
}
/// <summary>A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the
/// builder too — there's nothing past <see cref="Build"/>, matching Goedgekeurd/Afgewezen being
/// terminal in the domain (<see cref="BeoordelingRules.CanDecide"/>); a fixture that needs a
/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would.</summary>
public sealed class DecidedAanvraag
/// <summary>A submitted aanvraag with a behandelaar's decision already recorded — terminal in
/// the builder too, matching Goedgekeurd/Afgewezen being terminal in the domain
/// (<see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>); a fixture that
/// needs a SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would. Just a one-line
/// wrapper around the already-fully-built <see cref="Aanvraag.Decided"/> value — WP-73 moved
/// all the actual construction (and its invariant enforcement) into
/// <see cref="SubmittedAanvraag.Decided"/> itself, so there's nothing left for this type to do
/// except keep <c>.Decided(...).Build()</c> a valid two-call chain for the existing test
/// suite.</summary>
public sealed class DecidedAanvraag(Aanvraag.Decided value)
{
private readonly SubmittedAanvraag _submitted;
private readonly Besluit _besluit;
private readonly string? _toelichting;
internal DecidedAanvraag(SubmittedAanvraag submitted, Besluit besluit, string? toelichting)
{
_submitted = submitted;
_besluit = besluit;
_toelichting = toelichting;
}
public Aanvraag Build()
{
var aanvraag = _submitted.Build();
aanvraag.BesluitStatus = _besluit;
aanvraag.BesluitToelichting = _toelichting;
return aanvraag;
}
public Aanvraag.Decided Build() => value;
}
@@ -1,3 +1,4 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
using BigRegister.Tests.Builders;
@@ -7,7 +7,7 @@ public class HerregistratieRuleTests
private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
new RegistrationStatus.Geregistreerd(HerregistratieDatum: deadline));
[Fact]
public void Eligible_within_window()
@@ -40,18 +40,9 @@ public class HerregistratieRuleTests
{
var reg = Active(new DateOnly(2027, 3, 1)) with
{
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
Status = new RegistrationStatus.Geschorst(GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
};
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
Assert.False(eligible);
}
[Fact]
public void Status_consistency_invariant()
{
Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd)));
}
}
@@ -89,27 +89,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
Assert.Contains("application/problem+json", contentType.ToString());
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
// WP-69: 40 is below IntakePolicy.ScholingThreshold, so /intakes now requires the
// scholing question answered — `aanvullendeScholing` is unknown to (and ignored by)
// HerregistratieRequest, so this one extra field keeps serving both rows unchanged.
var res = await _client.PostAsJsonAsync(route, new { uren = 40, aanvullendeScholing = false });
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Change_request_with_valid_phone_succeeds()
{
@@ -156,7 +156,7 @@ public class OpenZaakZaakSourceTests
var zaakBody = handler.BodyOf($"{ZrcBase}/zaken");
Assert.Contains(zaaktypeUrl, zaakBody);
Assert.Contains("123443210", zaakBody);
Assert.Contains(aanvraag.Referentie!, zaakBody);
Assert.Contains(aanvraag.Referentie, zaakBody);
// Status: points at the created zaak's URL and the resolved statustype.
var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
@@ -310,7 +310,7 @@ public class OpenZaakZaakSourceTests
// --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path ---
private static (ZgwOptions options, Aanvraag aanvraag, CallerIdentity caller) CreateZaakFixture()
private static (ZgwOptions options, Aanvraag.Submitted aanvraag, CallerIdentity caller) CreateZaakFixture()
{
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
var options = new ZgwOptions
+4
View File
@@ -122,6 +122,10 @@ for its existing violations, so every WP ends green.
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | done |
| [WP-70](WP-70-test-data-builders.md) | Test-data builders: illegal fixtures unrepresentable (ADR-0006) | 12 · DDD hardening | done |
| [WP-71](WP-71-test-framework-coherence.md) | Test framework coherence: BDD/DDD alignment, close the escape hatches | 12 · DDD hardening | done |
| [WP-72](WP-72-delete-legacy-submit-endpoints.md) | Delete the dead legacy submit endpoints | 12 · DDD hardening | done |
| [WP-73](WP-73-domain-unions.md) | `RegistrationStatus` and `Aanvraag` as closed unions | 12 · DDD hardening | done |
| [WP-74](WP-74-e2e-isolation.md) | E2E isolation without a new backend endpoint | 12 · DDD hardening | done |
| [WP-75](WP-75-fe-be-seam-closure.md) | Close the remaining FE/BE seams | 12 · DDD hardening | done |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -0,0 +1,56 @@
# WP-72 — Delete the dead legacy submit endpoints
Status: done (6bc00a9)
Phase: 12 — DDD hardening
## Why
`POST /api/v1/intakes` and `POST /api/v1/herregistraties` were dead from the UI — the wizard
submits through `POST /applications/{id}/submit`, and nothing in `apps/` or `libs/` called the
generated `intakes()`/`herregistraties()` client methods. They were also strictly **less
capable** than the endpoint that replaced them: they minted a bare reference and wrote no
`Aanvraag`, made no ZGW/OpenZaak call, and performed no document-ownership check.
WP-69 hardened `/intakes` with a 400 last session. Deleting the surface is the stronger fix;
WP-69's `/applications/{id}/submit` enforcement — the path the wizard actually uses — is
untouched.
## Decisions (pre-made)
1. Delete both routes together. Their two `EndpointTests` are `[Theory]`s parameterised across
_both_ routes, so deleting one would leave an `InlineData` row 404-ing.
2. **Keep** the shared `Submit(...)` helper, `ReferentieResponse`, `SubmissionRules.NewReference`
and the whole `IdempotencyStore` path — `/registrations` and `/change-requests` still use
them, and `IdempotencyTests` covers the latter.
3. This WP owns the wire artifacts; no other track runs `gen:api`.
## Acceptance criteria
- [x] Both routes return **404** against a live backend (verified by curl, not by inference).
- [x] Zero references remain in `libs/shared/src/infrastructure/api-client.ts`.
- [x] `gen:api` diff is **pure deletion** — 124 lines out of `swagger.json`, 109 out of the API
client, zero additions.
- [x] Backend tests 245 → 240, exactly the 5 deleted cases (2 `[Theory]`s × 2 rows + 1 `[Fact]`).
- [x] `Submit(...)` and the idempotency path survive with their live callers intact.
## Verification
```bash
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration"
npm run ci
curl -X POST http://localhost:5000/api/v1/intakes -d '{"uren":500}' # 404
```
## Notes
Committed together with WP-73 (`6bc00a9`): both edit `Program.cs`, and splitting them would
have produced a commit that does not build. The two were run in separate execution waves to
avoid a concurrent `dotnet build` collision — but since neither committed independently, the
file-level entanglement remained at integration time. Worth remembering when planning future
parallel backend tracks: **separate waves do not produce separate commits.**
## Follow-ups
- `docs/reference/fp-tea-atomic-design.md:587` / `ARCHITECTURE.md:464` still teach a
`visibleSteps`-with-a-`'scholing'`-step intake the fixed-3-step wizard no longer matches
(inherited from WP-69).
@@ -0,0 +1,90 @@
# WP-73 — `RegistrationStatus` and `Aanvraag` as closed unions
Status: done (6bc00a9)
Phase: 12 — DDD hardening
## Why
Two backend domain types still allowed illegal states, against `CLAUDE.md`'s non-negotiable #3.
`RegistrationStatus` was a flat record whose **own doc-comment** admitted only `Geregistreerd`
should carry a herregistratie deadline — and noted the frontend modelled it correctly as a
discriminated union while the backend did not. It also made `reden` nullable on all three
variants where the FE requires it on two.
`Aanvraag` was a mutable EF class with 14 public setters. Its `StatusAt` carried **five
`Referentie!` null-forgiving derefs** plus a `SubmittedAt!.Value` — the compiler saying out loud
that "Submitted ⇒ Referentie != null" was convention, not type. WP-68 left it mutable
deliberately; WP-70/71 bought most of the safety with a test-only builder, which was itself a
hand-rolled prototype of the union this WP builds for real.
## Decisions (pre-made)
1. **Full union, not private setters.** The cheaper option (flip 14 setters to `private set`,
3 files, no migration) was rejected in favour of the honest modelling.
2. `RegistrationStatus` → abstract record + three sealed variants behind a private base ctor.
Chosen over WP-68's static-factory shape (`AanvraagStatus`) because with only 4 read sites the
abstract record is affordable and makes **reading** safe too, not just construction.
3. `Aanvraag``Concept | Submitted | Decided` (with `Decided` further split into
`Goedgekeurd | Afgewezen | MeerInfoGevraagd`), the EF row demoted to `AanvraagEntity` behind
a two-way mapper.
4. The `(Owner, Type)` "at most one unsubmitted aanvraag" rule is an **aggregate-set** invariant —
it cannot live on the entity and stays procedural in `CreateConcept` under the lock. Stated in
code so nobody tries to move it.
5. No migration, no schema change, no wire change.
## Acceptance criteria
- [x] **Illegal construction is a compile error, proven not assumed.** Each was attempted, the
compiler error recorded, then reverted:
| Attempted illegal state | Compiler error |
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `Decided` with no referentie | `CS9035: Required member 'Aanvraag.Decided.Referentie' must be set` |
| `Geschorst` with a HerregistratieDatum | `CS1739: The best overload for 'Geschorst' does not have a parameter named 'HerregistratieDatum'` |
| `Afwijzen` with no toelichting | `CS9035: Required member 'Aanvraag.Decided.Afgewezen.Toelichting' must be set` |
- [x] All five `Referentie!` derefs and the `SubmittedAt!.Value` are **gone**, not suppressed.
`IZaakSource.CreateZaak` narrows to `Aanvraag.Submitted`, removing the same class of deref
in both `LocalZaakSource` and `OpenZaakZaakSource`.
- [x] `reden` is now required on `Geschorst`/`Doorgehaald`, matching the FE union.
- [x] `HerregistratieRule.IsStatusConsistent` deleted as dead code — the type now guarantees what
it checked, and its test **could no longer construct the illegal state it existed to
catch**. That failure to compile is the proof the refactor worked.
- [x] Backend 242 → 241, exactly that one deleted test. No other count change.
- [x] `RegistrationStatusDto` and the application DTOs byte-identical — confirmed by diffing a
live backend's `/swagger/v1/swagger.json` against the checked-in copy. No `gen:api`.
## The `Draft` decision (made explicitly)
`ApplicationStore`'s doc-comment claimed `Draft` was "Concept only" (`Draft != null ⇒ !Submitted`),
but `Submit` never cleared it — so the invariant was **violated in production**. Resolved in
favour of the code matching the comment: `Submitted`/`Decided` simply have no `Draft` property,
so submitting drops it. Verified nothing reads a submitted aanvraag's draft — `draft-sync.ts`'s
`applyResume` is the only consumer of `ApplicationDetailDto.Draft` and only ever resumes an
unsubmitted wizard.
## Deviations
- **`Aanvraag` (EF row) renamed to `AanvraagEntity`.** The domain union needed the bare name to
match `RegistrationStatus`/`AanvraagStatus` conventions; keeping both would make every file
importing both namespaces ambiguous (`CS0104`). The table name is unaffected — EF derives it
from the `Applications` `DbSet` property, not the CLR type.
- **Step invariant loosened** from `0 <= StepIndex < StepCount` to `<=`: `CreateConcept` produces
`(0, 0)` before the wizard's first draft sync, which the strict form would reject at creation.
- `AanvraagBuilder.Decided(...)` now delegates to the real union constructors, dropping its own
hand-rolled toelichting guard; a one-line wrapper keeps the `.Decided(...).Build()` chain
source-compatible for existing call sites.
## Verification
```bash
cd backend && dotnet format --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration"
npm run ci
```
## Follow-ups
- Making `Besluit` flow through the generated client as an enum rather than a `string` would
remove that FE/BE seam entirely rather than guarding it (WP-75 added the guard) — but it is a
wire change.
@@ -0,0 +1,86 @@
# WP-74 — E2E isolation without a new backend endpoint
Status: done (42f7bd6)
Phase: 12 — DDD hardening
## Why
The three Playwright specs shared one mutable backend and admitted it in their own comments
("Restart the backend between CI runs — a second run would see a leftover Concept"). A crashed
mid-wizard run poisoned every subsequent run via `CreateConcept`'s 409, and both mutating specs
acted as the same identity (`DocumentStore.DemoOwner`), so any new state-touching spec would
collide immediately.
## Decisions (pre-made)
**WP-70 recorded the fix as a dev-only seed endpoint. That premise was wrong**, and exploration
established why:
- The DB path already routes through `IConfiguration` (`Program.cs`,
`Db.ConnectionString = GetConnectionString("AppDb") ?? …`), so `ConnectionStrings__AppDb` as an
env var gives a throwaway DB with **zero backend change** — the same trick
`TestWebApplicationFactory` already uses per-test.
- `StubIdentityProvider` **already honours** an `X-Subject` header; the only gap was that no FE
interceptor sent one.
- The backend has **no `IsDevelopment()` gate anywhere** (grep: zero hits), so a seed endpoint
would have had to invent the codebase's first environment gate — a new security posture for no
gain.
So: throwaway DB + a dev-only `X-Subject` interceptor. No new endpoint, no environment gate.
## Acceptance criteria
- [x] **`npm run e2e` passes twice back-to-back with no backend restart** — the actual acceptance
test, and the thing that failed before this WP.
- [x] **`X-Subject` observed on a real request** reaching the backend (`X-Subject: 111222333` on
`GET /api/v1/uploads/categories`), not merely wired.
- [x] Each new BSN elfproef-verified by script against the weights `[9,8,7,6,5,4,3,2,-1]`.
- [x] No backend change, no new endpoint, no `IsDevelopment()` gate.
- [x] Committed port config still defaults to 4200 (verification used an override).
## Notes on the two caveats
- **`reuseExistingServer` stays on.** Flipping it to `false` would hard-fail `npm run e2e` for
anyone already running the docker stack on 4200/5000 — a real local-workflow regression. The
consequence (the throwaway DB only applies when Playwright itself spawns the backend; always
true in CI) is documented in a comment on the `webServer` entry.
- **Unique DB filename per invocation**, with `global-setup.ts` sweeping only _prior_ runs'
leftovers. A fixed name unlinked mid-run is only safe if SQLite's pool never reopens by path
afterwards; under `fullyParallel` that risks silently recreating an empty, unmigrated DB.
## Deviation: interceptors alone were not enough
Two hand-written call sites bypass Angular's interceptor chain (as `CLAUDE.md` documents) and
needed `X-Subject` stamped explicitly:
- `libs/shared/src/upload/upload.adapter.ts`'s raw XHR upload — without this every uploaded
document landed under `DemoOwner`, breaking submit for any other identity.
- `apps/ssp/.../letter-preview.adapter.ts`'s preview fetch (plus `cache: 'no-store'`, correct
regardless since the endpoint sends no `Cache-Control`).
## Known gap (a real backend bug, not caused by this WP)
Under any BSN other than `DemoOwner`, `GET /brief/preview` returns a **sent** letter still
carrying the draft watermark — while `curl` against the same backend at the same instant returns
the correct frozen archive. Client caching was ruled out (`no-store`, then cache-busting query
strings), the dev proxy was ruled out, and it reproduced across two BSNs and never for
`DemoOwner`. This points at a staleness/race in `BriefStore`'s SQLite read path.
`brief-v2.spec.ts` therefore keeps the shared `zorgverlener` identity — it still gains
throwaway-DB repeatability, just not per-spec identity isolation. `actors.ts` reserves a
`briefOpsteller` actor for whoever fixes the backend. **Tracked as a follow-up below.**
## Verification
```bash
npm run e2e # twice consecutively, no backend restart
npm run lint && npm run typecheck && npm test && npm run build
```
Note: port 4200 was held by an unrelated container on the dev machine, so verification ran with
`E2E_BASE_URL` pointed at an alternate port. The committed default is unchanged.
## Follow-ups
- **`/brief/preview` staleness for non-`DemoOwner` identities** (above) — the blocker for giving
`brief-v2.spec.ts` its own identity.
@@ -0,0 +1,65 @@
# WP-75 — Close the remaining FE/BE seams
Status: done (6fa27d1)
Phase: 12 — DDD hardening
## Why
WP-71 added `scripts/check-seam.sh` guarding one literal pair (the scholing threshold) and
documented three further FE/BE duplications that nothing tested across the seam. This closes
them — two by deletion, one by a guard, one by an actual fix.
## Decisions (pre-made)
1. **Dead reference impls get deleted, and `CLAUDE.md` is amended.** This overturns the
documented policy that server-owned rules "stay in `domain/*.policy.ts` as reference impl +
unit test". That policy is precisely what kept dead code alive. Blast radius is small:
`registration.policy.ts` is the only `*.policy.ts` in the repo.
2. Guard the `Besluit` tag list by **extending** `check-seam.sh`, not adding a second script.
3. The phone seam gets a **contract test**, not a grep check — see below.
## Acceptance criteria
- [x] `isHerregistratieEligible` deleted (uncalled; dead by its own doc-comment) along with
`isStatusConsistent` (also uncalled — WP-71 had added a spec for it the session before).
The three live exports (`statusLabel`, `statusColor`, `herregistratieDeadline`) stay, and
`herregistratieDeadline` gained direct coverage it previously only had transitively.
- [x] `CLAUDE.md` amended: server-owned rules live **only** on the server; the FE may mirror a
server-supplied _value_ (a threshold, a bound) for instant feedback, but never
reimplements the _algorithm_. ADR-0001's matching claim aligned.
- [x] `check-seam.sh` guards the `Besluit` tag list, **proven to fail** when a fourth member is
added to the C# enum only, naming both files and both lists. Anchored on the full
declaration so it avoids the "greps all matches" trap WP-69 documented.
- [x] Phone contract test added and green; backend stripping fixed.
## The phone divergence was real, not latent
WP-71 recorded this as latent because the Angular app normalises before sending — true of _that_
path. The contract test proved the two sides genuinely disagreed: the backend returned **422**
for `+31612345678` and `(06) 12345678`, both of which the FE's own `parseTelefoonnummer`
accepts. Any non-Angular client, crafted POST, or future FE change would have hit it.
`SubmissionRules.RejectPhoneChange` now strips exactly what the FE strips (`[\s\-()]`, then a
leading `+31``0`) before applying the shared `^0\d{9}$`. The FE value object was not touched —
it is the more permissive and correct side.
**Why a contract test rather than a grep check:** both sides carry the identical `^0\d{9}$`
literal, so a drift check would have compared them, found them equal, and reported all clear.
The divergence was in the _normalisation before_ the regex — invisible to text comparison. Worth
remembering when choosing between the two guard styles: grep checks catch drifting **constants**,
contract tests catch drifting **behaviour**.
## Verification
```bash
npm run check:seam # both checks OK
npm run ci
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration"
```
## Follow-ups
- Making `Besluit` flow through the generated client as an enum rather than a `string` would
remove that seam entirely rather than guarding it — a wire change, so not done here.
- The herregistratie-eligibility seam is closed by deletion; if a FE mirror is ever reintroduced,
the disjoint-fixture problem returns and would need a contract test, not a grep check.
@@ -87,8 +87,8 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
- `BigProfileStore` now derives `profile` and `decisions` from the single
validated view (was a 3-resource `map2`). One request → one consistent snapshot.
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
calling `isHerregistratieEligible()`. That rule is now marked server-owned in
`registration.policy.ts` (kept as reference impl + unit test; FE no longer calls it).
computing it client-side. That rule is server-owned: it lives only in
`HerregistratieRule.cs`, with no FE mirror to drift from it (WP-75).
- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
`brp.json`) were deleted — those calls live behind the BFF now.
@@ -103,8 +103,10 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
- WP-69: the backend re-validates the threshold as the authority on submit —
`IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit`
(intake-typed) and the legacy `POST /intakes` write anything, 400ing an incomplete
scholing answer instead of silently accepting a crafted POST that skips it.
(intake-typed) writes anything, 400ing an incomplete scholing answer instead of
silently accepting a crafted POST that skips it. (WP-72 deleted the legacy
`POST /intakes` endpoint this once also covered — deleting the surface is a stronger
fix than 400ing on it.)
## Migration sequence (for the real app)
+16 -4
View File
@@ -7,10 +7,22 @@ import { Actors, loginAs } from './support/actors';
// Preview assertions are content-type/body-level (text/html + watermark marker), not
// pixel, per WP-28's decision.
//
// The backend persists to SQLite (WP-22) and is shared across runs: `/brief/reset`
// covers the letter, but org templates have no reset endpoint, so this test restores
// the org-template draft it edits (step 8) and never asserts an absolute version
// number — only that it increased by exactly one.
// This test mutates real state (a letter, keyed per-owner by `BriefStore.GetOrCreate`),
// and WP-74 gives it a fresh throwaway backend DB every `npm run e2e` run, so a
// leftover/in-progress letter from a PREVIOUS RUN is never an issue any more. It
// deliberately still logs in as the shared `Actors.zorgverlener` rather than its own
// BSN, though: giving it a distinct BSN (as `smoke.spec.ts` does) hit a real,
// reproducible bug in this repo's own e2e run — `GET /brief/preview`'s sent-letter
// response kept the DRAFT watermark under a non-`DemoOwner` `X-Subject`, even though
// the outgoing request carried the right header and a direct `curl` against the same
// backend at the same instant returned the correct, frozen archive. That points to a
// backend-side staleness/race in `BriefStore`'s SQLite read path (see
// `letter-preview.adapter.ts`'s "KNOWN GAP" note), out of WP-74's file scope to fix —
// so this spec stays on the one identity that doesn't trip it, pending that backend
// investigation. The org-template appearance is a SEPARATE, already-known gap: it's
// NOT owner-keyed (there's exactly one, shared by every caller) and has no reset
// endpoint, so this test still restores the org-template draft it edits (step 8) and
// never asserts an absolute version number — only that it increased by exactly one.
test('drafter composes → approver sends; admin republishes appearance', async ({ page }) => {
await loginAs(page, Actors.zorgverlener);
await expect(page).toHaveURL(/\/dashboard$/);
+39
View File
@@ -0,0 +1,39 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
/**
* WP-74: `playwright.config.ts` gives the backend a UNIQUE throwaway SQLite file
* per `npm run e2e` invocation (`<tmpdir>/big-register-e2e-<pid>-<timestamp>.db`),
* so Playwright has no webServer teardown hook to delete it once a run ends this
* sweeps them up instead, at the START of the NEXT run.
*
* Safe regardless of run ordering: `globalSetup` always executes AFTER `webServer`
* has already started (Playwright's task order, not something a config can flip),
* so THIS run's own file (`process.env['E2E_DB_PATH']`, set by the config module —
* same node process, so the assignment is visible here) is always excluded. Every
* OTHER matching file belongs to an invocation whose `dotnet run` process has
* already exited, so deleting it can't race a live connection.
*/
export default function globalSetup(): void {
const mine = process.env['E2E_DB_PATH'];
const dir = os.tmpdir();
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch {
return; // best-effort cleanup — a missing/unreadable temp dir isn't this run's problem
}
for (const name of entries) {
if (!name.startsWith('big-register-e2e-')) continue;
const base = name.replace(/(-shm|-wal)$/, '');
if (mine && base === path.basename(mine)) continue; // never this run's own file
try {
fs.unlinkSync(path.join(dir, name));
} catch {
// best-effort — a file another leftover process still has open, or already
// gone, is not worth failing this run's e2e suite over.
}
}
}
+7 -6
View File
@@ -6,13 +6,14 @@ import { Actors, loginAs, SeedRefs } from './support/actors';
// with zero policy questions, so the only required upload is identiteit), submit,
// and see the real confirmation. Not a full wizard-coverage suite — see WP-19.
//
// The backend is in-memory and shared across runs; this test mutates real state
// (creates a registratie application for the fixed demo identity). Restart the
// backend between CI runs — a second run would see a leftover Concept/submitted
// application on the dashboard, which this test doesn't assert against, but a
// stricter future test might.
// This test mutates real state (creates+submits a registratie application), so it
// logs in as its own BSN (`registratieAanvrager`, WP-74) rather than the shared
// `zorgverlener` — a leftover Concept from a previous run lands on THAT BSN's
// dashboard, not this one's, so a rerun (or another spec) never sees it. The
// backend itself also gets a fresh throwaway SQLite file per `npm run e2e`
// invocation (`playwright.config.ts`), so even a from-scratch run starts clean.
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
await loginAs(page, Actors.zorgverlener);
await loginAs(page, Actors.registratieAanvrager);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { level: 1, name: 'Mijn overzicht' })).toBeVisible();
+42 -7
View File
@@ -11,19 +11,54 @@ export interface Actor {
}
/**
* The demo identities seeded by the backend. Today there is exactly one seeded
* citizen (`backend/src/BigRegister.Api/Data/SeedData.cs`'s `Person`/`Registration`,
* whose BSN also matches `DocumentStore.DemoOwner`) named for the ROLE it plays
* in a spec, not its BSN, so a spec reads as "log in as the zorgverlener", not
* "log in as 123456782".
* The demo identities e2e specs log in as. The BRP/registration data every one of
* them sees on the dashboard comes from static `SeedData` and is identity-independent
* (only `DocumentStore.DemoOwner`'s owner-keyed Applications/Documents/Briefs differ
* per BSN see `subject.interceptor.ts`), so any elfproef-valid BSN works here; these
* are just distinct, not otherwise special.
*
* Named for the ROLE the identity plays in a spec, not its BSN, so a spec reads as
* "log in as the zorgverlener", not "log in as 123456782". **A spec that mutates
* owner-keyed state (creates a Concept, composes a brief, ) should use its own
* BSN** (WP-74) `subject.interceptor.ts` stamps it as `X-Subject`, so two specs
* sharing a BSN would collide on the same backend rows across the same run and
* across reruns. A read-only spec (nothing created/submitted) can keep using
* `zorgverlener`.
*
* `briefOpsteller` is the one exception, currently unused: `brief-v2.spec.ts` stays
* on `zorgverlener` despite mutating state, because giving it its own BSN tripped a
* real backend bug (a stale/watermarked `GET /brief/preview` response for the SENT
* letter under a non-`DemoOwner` owner see that spec's header comment and
* `letter-preview.adapter.ts`'s "KNOWN GAP" note). Kept defined, not deleted, so
* whoever fixes that backend issue has the identity ready to switch the spec onto.
*
* Every BSN below passed the elfproef (`libs/shared/src/kernel/bsn.ts`'s checksum)
* required, or `DigidAdapter.authenticate` rejects it and login never completes:
* 123456782 (9·1+8·2+7·3+6·4+5·5+4·6+3·7+2·81·2 = 154, 154 mod 11 = 0)
* 111222333 (9·1+8·1+7·1+6·2+5·2+4·2+3·3+2·31·3 = 66, 66 mod 11 = 0)
* 111111110 (9+8+7+6+5+4+3+20 = 44, 44 mod 11 = 0)
*/
export const Actors = {
/** The one seeded citizen (`SeedData.cs`'s `Person`/`Registration`) read-only
specs, and (for now see above) `brief-v2.spec.ts` too. */
zorgverlener: { bsn: '123456782', wachtwoord: 'demo' },
/** `smoke.spec.ts`'s own identity — it creates+submits a registratie-aanvraag. */
registratieAanvrager: { bsn: '111222333', wachtwoord: 'demo' },
/** Reserved for `brief-v2.spec.ts` once the backend bug above is fixed not
currently used by any spec. */
briefOpsteller: { bsn: '111111110', wachtwoord: 'demo' },
} as const satisfies Record<string, Actor>;
/** The shared DigiD-style mock login sequence every e2e spec starts from. */
/**
* The shared DigiD-style mock login sequence every e2e spec starts from. Navigating
* to `/login?subject=<bsn>` (rather than plain `/login`) primes `subject.interceptor.ts`'s
* sticky sessionStorage the same way `?role=` primes `roleInterceptor` (WP-33) the
* BSN typed into the form and the one the interceptor stamps as `X-Subject` are the
* same value by construction, they just can't share a single read (see
* `subject.interceptor.ts`'s doc comment for why not).
*/
export async function loginAs(page: Page, actor: Actor): Promise<void> {
await page.goto('/login');
await page.goto(`/login?subject=${actor.bsn}`);
await page.getByLabel('BSN').fill(actor.bsn);
await page.getByLabel('Wachtwoord').fill(actor.wachtwoord);
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
+16 -10
View File
@@ -20,8 +20,8 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 404 frontend behaviours across
8 contexts; 219 backend behaviours across 35 test
**is** the suite, reshaped for a business reader. 406 frontend behaviours across
8 contexts; 217 backend behaviours across 36 test
classes.
## Frontend (by context)
@@ -541,11 +541,9 @@ classes.
#### registration.policy
- only an active registration within the window is eligible
- struck-off / suspended registrations are never eligible
- statusLabel echoes the tag
- statusColor is total over the union
- a well-formed status is always consistent
- a Geregistreerd status without its herregistratieDatum is inconsistent
- herregistratieDeadline is only set for an active registration
#### submit
@@ -768,6 +766,13 @@ classes.
- keeps unrelated query params and the path/hash
- is a no-op when neither param is present
#### subjectInterceptor
- stamps X-Subject on an /api/v1/ request once ?subject= has been seen
- keeps stamping later requests on the same tab after the query param is gone (WP-33-style stickiness)
- leaves a non-API request untouched even when a subject is known
- sends no header at all when no subject has ever been seen
#### upload lifecycle messages
- queued → progress → complete
@@ -922,8 +927,6 @@ classes.
- IntakePolicy returns scholing threshold
- Registration with duo diploma succeeds
- Registration with manual diploma is rejected with problem details
- Zero hours submission is rejected
- Worked hours submission succeeds
- Change request with valid phone succeeds
- Change request with bad phone is rejected
- Health endpoint is ok
@@ -952,7 +955,6 @@ classes.
- Not eligible before window
- Eligible on window boundary
- Suspended is not eligible
- Status consistency invariant
### IdempotencyTests
@@ -977,7 +979,6 @@ classes.
- Punten without gevolgd is rejected
- Herregistratie is unaffected by the intake only gate
- Zero uren is still afgewezen not a 400
- Legacy intakes endpoint enforces it too
### LetterHtmlTests
@@ -1040,6 +1041,11 @@ classes.
- Rejects a margin outside the allowed range
- Accepts margins on the boundary
### PhoneFormatContractTests
- A leading plus31 is accepted like the frontends normalised form
- Parentheses around the area code are accepted like the frontend
### PreviewEndpointTests
- Preview of an unsent brief renders live with a watermark
@@ -405,104 +405,6 @@ export class ApiClient {
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
herregistraties(body: HerregistratieRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/herregistraties";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processHerregistraties(_response);
});
}
protected processHerregistraties(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
intakes(body: IntakeRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/intakes";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processIntakes(_response);
});
}
protected processIntakes(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
@@ -2205,21 +2107,10 @@ export interface HerregistratieDecisionsDto {
herregistratieReason?: string | undefined;
}
export interface HerregistratieRequest {
uren?: number;
documents?: DocumentRefDto[] | undefined;
}
export interface IntakePolicyDto {
scholingThreshold?: number;
}
export interface IntakeRequest {
uren?: number;
aanvullendeScholing?: boolean | undefined;
scholingPunten?: number | undefined;
}
export interface LetterBlockDto {
type?: string | undefined;
blockId?: string | undefined;
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { subjectInterceptor } from './subject.interceptor';
// currentSubject() reads window.location.search; set it via the real URL rather than
// vi.mock (the Angular unit-test system forbids mocking relative imports).
afterEach(() => {
window.history.replaceState({}, '', '/');
sessionStorage.clear(); // currentSubject() persists across calls; don't leak across tests
});
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
// the JIT compiler under vitest).
function fakeReq(url: string) {
const make = (headers: Map<string, string>) => ({
url,
headers,
clone(opts: { setHeaders: Record<string, string> }) {
const next = new Map(headers);
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
return make(next);
},
});
return make(new Map());
}
/** Run the interceptor and return the request it forwarded to `next`. */
function forward(url: string) {
let seen!: ReturnType<typeof fakeReq>;
const next = (r: ReturnType<typeof fakeReq>) => {
seen = r;
return undefined;
};
// Cast: the fake matches the shape the interceptor actually touches.
(subjectInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
return seen;
}
describe('subjectInterceptor', () => {
it('stamps X-Subject on an /api/v1/ request once ?subject= has been seen', () => {
window.history.replaceState({}, '', '/?subject=111222333');
expect(forward('/api/v1/registratie/concept').headers.get('X-Subject')).toBe('111222333');
});
it('keeps stamping later requests on the same tab after the query param is gone (WP-33-style stickiness)', () => {
window.history.replaceState({}, '', '/?subject=111222333');
forward('/api/v1/me');
window.history.replaceState({}, '', '/dashboard'); // navigation drops the query param
expect(forward('/api/v1/me').headers.get('X-Subject')).toBe('111222333');
});
it('leaves a non-API request untouched even when a subject is known', () => {
window.history.replaceState({}, '', '/?subject=111222333');
expect(forward('/assets/logo.svg').headers.has('X-Subject')).toBe(false);
});
it('sends no header at all when no subject has ever been seen', () => {
expect(forward('/api/v1/me').headers.has('X-Subject')).toBe(false);
});
});
@@ -0,0 +1,35 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { currentSubject } from './subject';
/**
* Dev-only (WP-74): stamps every API request with `X-Subject`, the BSN
* `StubIdentityProvider` (backend) resolves the caller's `ZorgverlenerCaller` from
* every owner-keyed store (`ApplicationStore`, `DocumentStore`, `BriefStore`) reads
* off that resolved identity, so this is the seam that lets e2e specs log in as
* distinct citizens and mutate independent rows instead of all colliding on
* `DocumentStore.DemoOwner`. Scoped like `medewerker.interceptor.ts` (every
* `/api/v1/*` request, not an allow-list like `roleInterceptor`) the identity
* middleware resolves a `CallerIdentity` for every request, not just some endpoints.
*
* **BSN source a deliberate compromise, read before changing:** the "obvious"
* source would be the authenticated `Session.bsn` held by each app's own
* `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context
* (the import-direction rule), and the one sanctioned cross-context seam
* `SessionPort` (`@shared/application/session.port`) deliberately exposes only
* `{ naam }`: `SessionStore`'s G1 comment is explicit that the BSN (a GDPR
* special-category identifier) is never persisted or otherwise handed outward, by
* design. Extending that port (or injecting `SessionStore` itself) would undo that
* boundary just to serve a dev/e2e convenience. So instead this reuses
* `role.interceptor.ts`'s own trick (see `subject.ts`, mirroring `role.ts`'s
* `currentRole()`): a `?subject=` seen in the URL is remembered in sessionStorage
* for the tab, and every later request reuses it. `e2e/support/actors.ts`'s
* `loginAs` sets it once per spec by navigating to `/login?subject=<bsn>` before
* filling in the login form. Outside e2e nothing ever sets `?subject=`, so no
* header is sent and the backend falls back to `DocumentStore.DemoOwner` exactly as
* before this WP.
*/
export const subjectInterceptor: HttpInterceptorFn = (req, next) => {
const subject = currentSubject();
if (!subject || !req.url.includes('/api/v1/')) return next(req);
return next(req.clone({ setHeaders: { 'X-Subject': subject } }));
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see
* `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This
* POC has no real DigiD identity `Session.bsn` lives only in each app's own
* in-memory `SessionStore` and is deliberately never persisted (see that store's G1
* comment) so `subject.interceptor.ts` can't reach it without a layering
* violation (`libs/shared` may not depend on an app-local `auth` context). Instead a
* `?subject=<bsn>` query param, seen once on any navigation, is remembered for the
* tab in sessionStorage the exact `?role=` trick `role.ts` already uses (WP-33).
*
* Two consumers read this, both dev/e2e-only: `subject.interceptor.ts` (every
* `HttpClient` request) and `letter-preview.adapter.ts` (`/brief/preview`'s
* hand-written `fetch`, which bypasses every `HttpInterceptorFn` the same reason
* that adapter already sets `X-Role` explicitly via `currentRole()`).
*
* `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike
* `currentRole()` (a closed enum with a sensible default), there is no "default
* subject" to fall back to here omitting the header entirely lets the backend's
* own default (`DocumentStore.DemoOwner`) apply, exactly as if this WP didn't exist.
*/
const STORAGE_KEY = 'dev-subject';
export function currentSubject(): string | undefined {
const fromUrl = new URLSearchParams(window.location.search).get('subject');
if (fromUrl) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
return sessionStorage.getItem(STORAGE_KEY) ?? undefined;
}
+8
View File
@@ -6,6 +6,7 @@ import {
} from '@shared/infrastructure/api-client';
import { problemDetail } from '@shared/infrastructure/api-error';
import { currentScenario } from '@shared/infrastructure/scenario';
import { currentSubject } from '@shared/infrastructure/subject';
import { environment } from '@shared/environments/environment';
import { DocumentCategory } from './upload.machine';
@@ -144,6 +145,13 @@ export class UploadAdapter {
});
xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`);
// WP-74: this XHR bypasses `HttpClient`'s `subjectInterceptor` (the same reason
// `letter-preview.adapter.ts` sets `X-Role` explicitly) — without `X-Subject` a
// document always uploaded under `DocumentStore.DemoOwner` regardless of who was
// actually logged in, so a submission attempted under any other BSN would find
// its own required document "missing" (owned by someone else).
const subject = currentSubject();
if (subject) xhr.setRequestHeader('X-Subject', subject);
xhr.send(form);
return { done, cancel: () => ((aborted = true), xhr.abort()) };
}
+47
View File
@@ -1,15 +1,48 @@
import { defineConfig } from '@playwright/test';
import * as os from 'node:os';
import * as path from 'node:path';
// Smoke-level e2e (WP-19): one happy path, one degraded path, against the REAL
// backend — proving the FE+BE seam, not replacing component/unit tests.
const baseURL = process.env['E2E_BASE_URL'] ?? 'http://localhost:4200';
// WP-74: give the backend a THROWAWAY SQLite file per `npm run e2e` invocation
// instead of its default `bigregister.db`, so two separate runs never see each
// other's leftover Applications/Documents/Briefs (`Program.cs:44-46` already reads
// `ConnectionStrings__AppDb` from `IConfiguration` — zero backend change; the same
// trick `TestWebApplicationFactory.cs` uses per xUnit test class). `Program.cs:93`
// self-migrates a fresh file on startup, so a brand-new path is immediately usable.
//
// UNIQUE filename per invocation (pid + timestamp), not a single fixed name: a fixed
// name that gets deleted WHILE the backend still has it open only stays safe if
// Microsoft.Data.Sqlite's connection pool never needs to open a fresh native handle
// by path after the delete — under `fullyParallel: true` (several workers hitting
// the backend concurrently) a pool miss is a real, if intermittent, risk, and it
// would silently recreate an empty, unmigrated file mid-run (every query after that
// would 500 on "no such table"). A unique path sidesteps the whole question: nothing
// ever deletes the file THIS run is actively using. The tradeoff is litter in the OS
// temp dir across many runs, since Playwright has no webServer teardown hook to
// delete it once the run ends — `globalSetup` (below) sweeps prior runs' files at
// the start of each new run instead. That's safe regardless of ordering: it only
// ever touches OTHER runs' paths, whose processes have already exited.
const dbPath = path.join(os.tmpdir(), `big-register-e2e-${process.pid}-${Date.now()}.db`);
// Shared with `e2e/global-setup.ts`, which runs in this SAME node process (it's
// loaded and invoked by the Playwright runner right after `webServer` comes up, not
// spawned separately) — so this assignment is visible there without a second import.
process.env['E2E_DB_PATH'] = dbPath;
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
fullyParallel: true,
retries: process.env['CI'] ? 1 : 0,
reporter: process.env['CI'] ? 'github' : 'list',
// Sweeps `big-register-e2e-*.db*` files left behind by EARLIER invocations (never
// this run's own `dbPath` — see its comment above). Runs after `webServer` is
// already up (Playwright always starts webServer before globalSetup — there's no
// config knob to reverse that), which is exactly why it must never touch the
// current run's own file.
globalSetup: './e2e/global-setup.ts',
use: {
baseURL,
trace: 'on-first-retry',
@@ -27,8 +60,22 @@ export default defineConfig({
{
command: 'dotnet run --project backend/src/BigRegister.Api --urls http://localhost:5000',
url: 'http://localhost:5000/swagger',
// WP-74 caveat, read before "fixing" this to `false`: this stays `!CI` (reuse
// locally) on purpose, matching the FE server below and the docker-stack note
// above it — turning reuse off would make `npm run e2e` hard-fail with
// "port already in use" for anyone who already has the docker stack (or a
// plain `dotnet run`) up on :5000, a real local-workflow regression for a POC
// convenience feature. The tradeoff this buys: `env` below (the throwaway DB)
// ONLY applies when Playwright itself spawns the process — reusing an
// already-running backend silently falls back to THAT process's own DB
// (typically the shared dev `bigregister.db`), so the isolation this WP adds
// is real in CI (`reuseExistingServer` is always `false` there) and in the
// common local case of "nothing was already running on :5000", but not if you
// deliberately point e2e at an already-running shared backend — that was
// already shared state before this WP and still is.
reuseExistingServer: !process.env['CI'],
timeout: 180_000,
env: { ConnectionStrings__AppDb: `Data Source=${dbPath}` },
},
{
command: 'npm start',
+32
View File
@@ -31,3 +31,35 @@ if [ "$backend_value" != "$frontend_value" ]; then
fi
echo "OK: scholing threshold default matches on both sides ($backend_value)"
# WP-75: fail if the backend's Besluit enum and the frontend's BESLUIT_TAGS list (the wire
# convention: a string, not a raw enum) drift apart. Enum.TryParse<Besluit> at Program.cs:494
# is the only coupling and it fails at REQUEST time, not build time — this is a build-time
# tripwire for the same names/order both sides assume.
BESLUIT_BACKEND_FILE='backend/src/BigRegister.Api/Domain/Applications/AanvraagStatus.cs'
BESLUIT_FRONTEND_FILE='apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts'
besluit_backend_raw=$(grep -oE 'public enum Besluit \{[^}]*\}' "$BESLUIT_BACKEND_FILE" | grep -oE '\{[^}]*\}')
besluit_frontend_raw=$(grep -oE "const BESLUIT_TAGS = \[[^]]*\]" "$BESLUIT_FRONTEND_FILE" | grep -oE '\[[^]]*\]')
if [ -z "$besluit_backend_raw" ]; then
echo "FAIL: could not find 'public enum Besluit { ... }' in $BESLUIT_BACKEND_FILE"
exit 1
fi
if [ -z "$besluit_frontend_raw" ]; then
echo "FAIL: could not find 'const BESLUIT_TAGS = [ ... ]' in $BESLUIT_FRONTEND_FILE"
exit 1
fi
besluit_backend_value=$(echo "$besluit_backend_raw" | tr -d '{}' | tr -d ' ')
besluit_frontend_value=$(echo "$besluit_frontend_raw" | tr -d '[]' | tr -d "' " )
if [ "$besluit_backend_value" != "$besluit_frontend_value" ]; then
echo "FAIL: FE/BE seam drift on the Besluit tag list"
echo " $BESLUIT_BACKEND_FILE: Besluit { $besluit_backend_value }"
echo " $BESLUIT_FRONTEND_FILE: BESLUIT_TAGS = [ $besluit_frontend_value ]"
echo 'Both lists are the same wire-convention names/order (Enum.TryParse<Besluit> at Program.cs) and must match.'
exit 1
fi
echo "OK: Besluit tag list matches on both sides ($besluit_backend_value)"