feat(boundaries): WP-04 — ui ↛ infrastructure + showcase sanction

Move the two wizard lookups behind application-layer facades so ui/ no longer
injects infrastructure adapters directly:
- RegistratieLookupStore (BRP address + DUO diplomas): owns the resources,
  runs the trust-boundary parse, exposes adresStatus/prefillAdres/duoLookup.
- IntakePolicyStore (scholing threshold): owns the policy resource, exposes
  the derived threshold.

Add the lint rule ui/ + layout/ ↛ **/infrastructure/** (@typescript-eslint
variant so it composes with the base direction rules; stories/specs exempted
as test scaffolding). Add the documented showcase sanction (may read every
context). Fix the docs' inventory: 6 contexts / 5 layers, +brief, +contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:01:42 +02:00
parent 3a5c8f157a
commit 035e785c95
10 changed files with 513 additions and 227 deletions

View File

@@ -14,9 +14,8 @@ import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
import { RemoteData } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
@@ -194,8 +193,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
`,
})
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private lookup = inject(RegistratieLookupStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
@@ -204,9 +202,6 @@ export class RegistratieWizardComponent {
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
protected adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
@@ -286,25 +281,10 @@ export class RegistratieWizardComponent {
protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or"geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
served by the application facade — the wizard renders, it does not fetch/parse. */
protected adresStatus = this.lookup.adresStatus;
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
@@ -362,21 +342,17 @@ export class RegistratieWizardComponent {
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
// make this effect loop on its own write). Don't clobber edits/restored data.
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
// would otherwise make this effect loop on its own write). Don't clobber
// edits/restored data. A null prefill (loading/error/geen adres) leaves manual entry.
effect(() => {
const json = this.adresRes.value();
if (!json) return;
const parsed = parseBrpAddress(json);
const a = this.lookup.prefillAdres();
if (!a) return;
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
const a = parsed.value.adres;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
}
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
});
});
// A11y: focus management (step heading on step change, error summary on a
@@ -400,7 +376,7 @@ export class RegistratieWizardComponent {
restart() {
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
this.dispatch({ tag: 'Seed', state: initial });
this.adresRes.reload();
this.lookup.reloadAdres();
}
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle