Compare commits
4
Commits
edaf1360c5
...
ae7781efef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7781efef | ||
|
|
6fa27d1c53 | ||
|
|
42f7bd651d | ||
|
|
6bc00a917c |
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <= StepIndex <= 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <= <paramref name="stepIndex"/> <= <paramref name="stepCount"/> — the
|
||||
/// non-strict upper bound, not the strict "<" 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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (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
@@ -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$/);
|
||||
|
||||
@@ -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
@@ -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
@@ -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·8−1·2 = 154, 154 mod 11 = 0)
|
||||
* 111222333 ✓ (9·1+8·1+7·1+6·2+5·2+4·2+3·3+2·3−1·3 = 66, 66 mod 11 = 0)
|
||||
* 111111110 ✓ (9+8+7+6+5+4+3+2−0 = 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();
|
||||
|
||||
@@ -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 } }));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user