diff --git a/apps/ssp/src/app/registratie/application/big-profile.store.ts b/apps/ssp/src/app/registratie/application/big-profile.store.ts index 704429c..c590e96 100644 --- a/apps/ssp/src/app/registratie/application/big-profile.store.ts +++ b/apps/ssp/src/app/registratie/application/big-profile.store.ts @@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { RemoteData, fromResource, map } from '@shared/application/remote-data'; import { Aantekening } from '../domain/registration'; import { BigProfile } from '../domain/big-profile'; -import { HerregistratieDecisions } from '../contracts/dashboard-view.dto'; +import { HerregistratieDecisions } from '../domain/registration'; import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; import { DashboardView, diff --git a/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts b/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts deleted file mode 100644 index d681a7d..0000000 --- a/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call). - * - * In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our - * own backend, which talks to the BRP behind an adapter. The frontend never sees - * the BRP's own wire format. See docs/reference/architecture/0001-bff-lite-decision-dtos.md. - * - * "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error — - * the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy - * path (gevonden: true). - */ -export interface BrpAddressDto { - gevonden: boolean; - adres?: { straat: string; postcode: string; woonplaats: string }; -} diff --git a/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts b/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts deleted file mode 100644 index d156ec7..0000000 --- a/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WIRE CONTRACT for the dashboard screen — the "BFF-lite" response. - * - * PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are - * inlined string-literal unions that describe the wire, not the domain. The - * adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto - * the FE domain model (Registration/Person/BigProfile) — that map is the - * decoupling seam: the wire can change without the domain following. - * - * In production these types are GENERATED from the OpenAPI/TypeSpec spec (one - * source of truth for both sides), and the `decisions` block is computed BY THE - * BACKEND — never recomputed on the client. The frontend renders decisions; it - * does not own the rules. See docs/reference/architecture/0001-bff-lite-decision-dtos.md. - * - * One screen-shaped call replaces the previous three (BIG-register + BRP + …), - * so the page always sees one consistent snapshot instead of three independently - * loading/erroring resources. - */ - -/** Registration status on the wire: the discriminant tags as they arrive. */ -export type RegistrationStatusDto = - | { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date - | { tag: 'Geschorst'; geschorstTot: string; reden: string } - | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string }; - -export interface RegistrationDto { - bigNummer: string; - naam: string; - beroep: string; - registratiedatum: string; // ISO date - geboortedatum: string; - status: RegistrationStatusDto; -} - -export interface AdresDto { - straat: string; - postcode: string; - woonplaats: string; -} - -export interface PersonDto { - naam: string; - geboortedatum: string; // ISO date - adres: AdresDto; -} - -/** Server-computed decisions. Rendered by the FE as-is (decision DTO, ADR-0001): - the eligibility rule lives on the backend; the optional reason lets the UI - explain itself without knowing the rule. */ -export interface HerregistratieDecisions { - eligibleForHerregistratie: boolean; - herregistratieReason?: string; -} - -export interface DashboardViewDto { - registration: RegistrationDto; - person: PersonDto; - decisions: HerregistratieDecisions; -} diff --git a/apps/ssp/src/app/registratie/domain/registration.ts b/apps/ssp/src/app/registratie/domain/registration.ts index 2dba1c3..94af1fb 100644 --- a/apps/ssp/src/app/registratie/domain/registration.ts +++ b/apps/ssp/src/app/registratie/domain/registration.ts @@ -33,3 +33,11 @@ export interface Aantekening { omschrijving: string; datum: string; } + +/** Server-computed eligibility for herregistratie (ADR-0001 decision DTO): the FE + renders this as-is, it never recomputes the rule. The optional reason lets the + UI explain a "not eligible" outcome without knowing why. */ +export interface HerregistratieDecisions { + eligibleForHerregistratie: boolean; + herregistratieReason?: string; +} diff --git a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts index c343ba1..512c221 100644 --- a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { BrpAddressDto } from '@registratie/contracts/brp-address.dto'; +import { BrpAddressDto } from '@shared/infrastructure/api-client'; import { ApiClient } from '@shared/infrastructure/api-client'; /** diff --git a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts index e2fe78d..2ad78d7 100644 --- a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts +++ b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts @@ -35,4 +35,43 @@ describe('parseDashboardView (trust boundary)', () => { parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok, ).toBe(false); }); + + it('rejects a status whose tag is present but its required fields are missing', () => { + // The generated RegistrationStatusDto flattens the union — every field is + // optional, so a wire bug (a Geregistreerd row with no herregistratieDatum) + // must be caught here, not by the compiler. + expect( + parseDashboardView({ + ...valid, + registration: { ...valid.registration, status: { tag: 'Geregistreerd' } }, + }).ok, + ).toBe(false); + expect( + parseDashboardView({ + ...valid, + registration: { + ...valid.registration, + status: { tag: 'Geschorst', geschorstTot: '2027-01-01' }, // missing reden + }, + }).ok, + ).toBe(false); + }); + + it('rejects an unknown status tag', () => { + expect( + parseDashboardView({ + ...valid, + registration: { ...valid.registration, status: { tag: 'Ingetrokken' } }, + }).ok, + ).toBe(false); + }); + + it('rejects a person with an incomplete adres', () => { + expect( + parseDashboardView({ + ...valid, + person: { ...valid.person, adres: { straat: 'X 1' } }, + }).ok, + ).toBe(false); + }); }); diff --git a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts index 1a55742..7791ea8 100644 --- a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -1,18 +1,24 @@ import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { + ApiClient, DashboardViewDto, + RegistrationDto, + RegistrationStatusDto, + PersonDto, +} from '@shared/infrastructure/api-client'; +import { + Registration, + RegistrationStatus, HerregistratieDecisions, -} from '@registratie/contracts/dashboard-view.dto'; -import { Registration } from '@registratie/domain/registration'; +} from '@registratie/domain/registration'; import { Person } from '@registratie/domain/person'; import { BigProfile } from '@registratie/domain/big-profile'; -import { ApiClient } from '@shared/infrastructure/api-client'; /** * The parsed, frontend-side view: the wire DTO mapped onto our own domain model. - * Lives HERE, not in contracts/, because it references domain types — contracts - * stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001). + * Lives HERE, not in a `contracts/*.dto.ts` file, because it references domain + * types — that split is the decoupling seam (CLAUDE.md §1, ADR-0001). */ export interface DashboardView { profile: BigProfile; @@ -31,17 +37,80 @@ export class DashboardViewAdapter { // The value is still untrusted JSON — parseDashboardView validates it at the // boundary and maps DTO → domain before the app uses it. - // - // SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here — - // e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the - // adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls - // (the submit-* commands) must NEVER auto-retry — and don't. Manual retry - // (resource.reload via ) covers the UX today, so backoff stays unbuilt. dashboardViewResource() { return resource({ loader: () => this.client.dashboardView() }); } } +/** Trust-boundary parse of the status union — the generated `RegistrationStatusDto` + flattens all three variants into one object with every field optional (NSwag + can't express a discriminated union), so the tag drives which fields must + actually be present. Mirrors `parseAanvraagStatus` in `aanvragen.adapter.ts`. */ +export function parseRegistrationStatus( + s: RegistrationStatusDto | undefined, +): Result { + if (!s || typeof s.tag !== 'string') return err('registration: missing status'); + switch (s.tag) { + case 'Geregistreerd': + if (typeof s.herregistratieDatum !== 'string') + return err('registration: bad Geregistreerd status'); + return ok({ tag: 'Geregistreerd', herregistratieDatum: s.herregistratieDatum }); + case 'Geschorst': + if (typeof s.geschorstTot !== 'string' || typeof s.reden !== 'string') + return err('registration: bad Geschorst status'); + return ok({ tag: 'Geschorst', geschorstTot: s.geschorstTot, reden: s.reden }); + case 'Doorgehaald': + if (typeof s.doorgehaaldOp !== 'string' || typeof s.reden !== 'string') + return err('registration: bad Doorgehaald status'); + return ok({ tag: 'Doorgehaald', doorgehaaldOp: s.doorgehaaldOp, reden: s.reden }); + default: + return err(`registration: unknown status tag ${s.tag}`); + } +} + +function parseRegistration(dto: RegistrationDto | undefined): Result { + if ( + !dto || + typeof dto.bigNummer !== 'string' || + typeof dto.naam !== 'string' || + typeof dto.beroep !== 'string' || + typeof dto.registratiedatum !== 'string' || + typeof dto.geboortedatum !== 'string' + ) { + return err('dashboard-view: missing/invalid registration'); + } + const status = parseRegistrationStatus(dto.status); + if (!status.ok) return status; + return ok({ + bigNummer: dto.bigNummer, + naam: dto.naam, + beroep: dto.beroep, + registratiedatum: dto.registratiedatum, + geboortedatum: dto.geboortedatum, + status: status.value, + }); +} + +function parsePerson(dto: PersonDto | undefined): Result { + const a = dto?.adres; + if ( + !dto || + typeof dto.naam !== 'string' || + typeof dto.geboortedatum !== 'string' || + !a || + typeof a.straat !== 'string' || + typeof a.postcode !== 'string' || + typeof a.woonplaats !== 'string' + ) { + return err('dashboard-view: missing/invalid person'); + } + return ok({ + naam: dto.naam, + geboortedatum: dto.geboortedatum, + adres: { straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }, + }); +} + /** * Trust-boundary parse: validate the untrusted response shape and map the DTO * onto our own domain model. Hand-written on purpose — no Zod for a single @@ -51,43 +120,17 @@ export function parseDashboardView(json: unknown): Result if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object'); const dto = json as Partial; - const reg = dto.registration; - if ( - !reg || - typeof reg.bigNummer !== 'string' || - !reg.status || - typeof reg.status.tag !== 'string' - ) { - return err('dashboard-view: missing/invalid registration'); - } - const person = dto.person; - if (!person || !person.adres || typeof person.adres.postcode !== 'string') { - return err('dashboard-view: missing/invalid person'); - } + const registration = parseRegistration(dto.registration); + if (!registration.ok) return registration; + const person = parsePerson(dto.person); + if (!person.ok) return person; const d = dto.decisions; if (!d || typeof d.eligibleForHerregistratie !== 'boolean') { return err('dashboard-view: missing/invalid decisions'); } - // Map wire → domain. The shapes are identical today, so this reads as an - // identity copy — but the TYPES differ (wire DTO vs domain), so the moment the - // wire diverges the compiler forces a real mapping here. That's the seam. - const registration: Registration = { - bigNummer: reg.bigNummer, - naam: reg.naam, - beroep: reg.beroep, - registratiedatum: reg.registratiedatum, - geboortedatum: reg.geboortedatum, - status: reg.status, - }; - const persoon: Person = { - naam: person.naam, - geboortedatum: person.geboortedatum, - adres: person.adres, - }; - return ok({ - profile: { registration, person: persoon }, + profile: { registration: registration.value, person: person.value }, decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason,