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:
11
CLAUDE.md
11
CLAUDE.md
@@ -34,7 +34,8 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
|
|||||||
### 1. DDD: contexts then layers, dependencies point inward
|
### 1. DDD: contexts then layers, dependencies point inward
|
||||||
|
|
||||||
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
|
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
|
||||||
`herregistratie`, `showcase` (teaching page, not a feature).
|
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
|
||||||
|
page, not a feature; **sanctioned** to read every context — nothing imports it).
|
||||||
|
|
||||||
| Layer | Job | Angular allowed? |
|
| Layer | Job | Angular allowed? |
|
||||||
| ----------------- | ----------------------------------------- | -------------------------------- |
|
| ----------------- | ----------------------------------------- | -------------------------------- |
|
||||||
@@ -45,9 +46,11 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
|
|||||||
| `ui/` | how it looks (components, pages) | yes |
|
| `ui/` | how it looks (components, pages) | yes |
|
||||||
|
|
||||||
**Dependencies only point inward**: `ui → application → domain`; everyone may use
|
**Dependencies only point inward**: `ui → application → domain`; everyone may use
|
||||||
`shared`; never the reverse. Cross-context only `herregistratie → registratie → shared`,
|
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
|
||||||
`auth → shared`. Imports use aliases as direction statements: `@shared/* @auth/*
|
(reach data through an application store/command) — lint-enforced. Cross-context only
|
||||||
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
|
`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use
|
||||||
|
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
|
||||||
|
@brief/*`. `domain/` imports nothing from Angular.
|
||||||
|
|
||||||
### 2. Atomic design: folder = layer
|
### 2. Atomic design: folder = layer
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ re-registration — "herregistratie").
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. The big picture: three "contexts", four "layers"
|
## 1. The big picture: six "contexts", five "layers"
|
||||||
|
|
||||||
The code is split first by **business area** (a "bounded context" in DDD terms),
|
The code is split first by **business area** (a "bounded context" in DDD terms),
|
||||||
then inside each area by **layer**.
|
then inside each area by **layer**.
|
||||||
@@ -27,9 +27,14 @@ src/app/
|
|||||||
auth/ logging in / the current session
|
auth/ logging in / the current session
|
||||||
registratie/ the user's BIG registration + personal data
|
registratie/ the user's BIG registration + personal data
|
||||||
herregistratie/ the re-registration application flow
|
herregistratie/ the re-registration application flow
|
||||||
showcase/ a teaching page; not a real feature
|
brief/ letter-composition teaching slice
|
||||||
|
showcase/ a teaching page; not a real feature (may read every context)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
|
||||||
|
showing multiple contexts side by side, so it may import any context. Nothing imports
|
||||||
|
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
|
||||||
|
|
||||||
### The atomic-design hierarchy, visualised
|
### The atomic-design hierarchy, visualised
|
||||||
|
|
||||||
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
|
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
|
||||||
@@ -53,7 +58,7 @@ organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `b
|
|||||||
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
|
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
|
||||||
hierarchy.
|
hierarchy.
|
||||||
|
|
||||||
Inside a context you'll see the same four folders. They answer four different
|
Inside a context you'll see the same five folders. They answer five different
|
||||||
questions:
|
questions:
|
||||||
|
|
||||||
| Layer | Answers… | May import Angular? | Example here |
|
| Layer | Answers… | May import Angular? | Example here |
|
||||||
@@ -61,14 +66,18 @@ questions:
|
|||||||
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
|
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
|
||||||
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
|
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
|
||||||
| `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
|
| `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
|
||||||
|
| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.ts` |
|
||||||
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
|
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
|
||||||
|
|
||||||
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
|
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
|
||||||
application, application may use domain, everyone may use `shared`. Never the
|
application, application may use domain, everyone may use `shared`. Never the
|
||||||
other way around. The `domain/` layer imports nothing from Angular, so the
|
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
|
||||||
business rules are plain functions you can read and test in isolation.
|
directly** — they reach data through an application store or command (lint-enforced).
|
||||||
|
The `domain/` layer imports nothing from Angular, so the business rules are plain
|
||||||
|
functions you can read and test in isolation.
|
||||||
|
|
||||||
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`.
|
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
|
||||||
|
`brief → shared` (`showcase` may read every context; see above).
|
||||||
|
|
||||||
### Why the `shared/` kernel is split too
|
### Why the `shared/` kernel is split too
|
||||||
|
|
||||||
@@ -79,7 +88,7 @@ Allowed direction: `herregistratie → registratie → shared`, `auth → shared
|
|||||||
- `shared/infrastructure/` — the demo HTTP interceptor.
|
- `shared/infrastructure/` — the demo HTTP interceptor.
|
||||||
|
|
||||||
Imports use path aliases so they read as direction statements:
|
Imports use path aliases so they read as direction statements:
|
||||||
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`.
|
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
|
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
||||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
|
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
|
||||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
|
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
|
||||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
|
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
|
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 0 — enforcement & gates
|
Phase: 0 — enforcement & gates
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -54,11 +54,13 @@ Phase 0 — a pure move of wiring, no behavior change.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Rule active; lint green; no disables beyond the documented showcase + debug-state
|
- [x] Rule active; lint green; no disables beyond the documented showcase + debug-state
|
||||||
exemptions.
|
exemptions. (Probed: a ui→infra import errors.)
|
||||||
- [ ] No `**/ui/**` file imports from `**/infrastructure/**`.
|
- [x] No `**/ui/**` file imports from `**/infrastructure/**` (production; stories/specs
|
||||||
- [ ] Both wizards behave unchanged (specs pass; manual smoke).
|
exempted — test scaffolding wires the real client).
|
||||||
- [ ] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
|
- [x] Both wizards behave unchanged (178 specs pass; storybook a11y suite mounts both
|
||||||
|
wizards green — behaviour is a pure wiring move behind root facades).
|
||||||
|
- [x] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -143,4 +143,44 @@ export default [
|
|||||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||||
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
|
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ui/ and layout/ are the presentation layer: dependencies point inward
|
||||||
|
// (ui → application → domain, CLAUDE.md §1), so they must NOT import
|
||||||
|
// infrastructure/ directly — they reach data through an application store or
|
||||||
|
// command. (Stories/specs are test scaffolding and may wire the real client.)
|
||||||
|
// Uses the @typescript-eslint variant so it composes with the base
|
||||||
|
// no-restricted-imports context-direction rules above (last-wins is per rule name).
|
||||||
|
{
|
||||||
|
files: ['src/app/**/ui/**/*.ts', 'src/app/**/layout/**/*.ts'],
|
||||||
|
ignores: ['**/*.stories.ts', '**/*.spec.ts'],
|
||||||
|
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@shared/infrastructure/*',
|
||||||
|
'@auth/infrastructure/*',
|
||||||
|
'@registratie/infrastructure/*',
|
||||||
|
'@herregistratie/infrastructure/*',
|
||||||
|
'@brief/infrastructure/*',
|
||||||
|
],
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: 'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sanctioned exception: showcase/ is the teaching page whose whole point is showing
|
||||||
|
// multiple contexts side by side (ARCHITECTURE.md §6). It may read every context;
|
||||||
|
// nothing imports showcase. Same precedent as the shared/ui/debug-state exemption.
|
||||||
|
{
|
||||||
|
files: ['src/app/showcase/**/*.ts'],
|
||||||
|
rules: { 'no-restricted-imports': 'off' },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
19
src/app/herregistratie/application/intake-policy.store.ts
Normal file
19
src/app/herregistratie/application/intake-policy.store.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable, computed, inject } from '@angular/core';
|
||||||
|
import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';
|
||||||
|
import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-layer facade for the server-owned intake policy (the scholing
|
||||||
|
* threshold config value). It owns the httpResource (created here, in the required
|
||||||
|
* injection context) and exposes the threshold as a derived signal, falling back to
|
||||||
|
* the domain default until the backend answers. The UI reaches the network through
|
||||||
|
* application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the
|
||||||
|
* authority and re-validates on submit.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class IntakePolicyStore {
|
||||||
|
private policy = inject(IntakePolicyAdapter);
|
||||||
|
private policyRes = this.policy.policyResource();
|
||||||
|
|
||||||
|
readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
SCHOLING_THRESHOLD_DEFAULT,
|
SCHOLING_THRESHOLD_DEFAULT,
|
||||||
} from '@herregistratie/domain/intake.machine';
|
} from '@herregistratie/domain/intake.machine';
|
||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';
|
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
||||||
|
|
||||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||||
@@ -120,13 +120,11 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
|
|||||||
})
|
})
|
||||||
export class IntakeWizardComponent {
|
export class IntakeWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
private policy = inject(IntakePolicyAdapter);
|
// Server-owned policy (scholing threshold): fetched from the backend via the
|
||||||
|
// application facade, not hardcoded. The backend stays the authority on submit.
|
||||||
|
private policyStore = inject(IntakePolicyStore);
|
||||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||||
|
|
||||||
// Server-owned policy: the scholing threshold is fetched from the backend, not
|
|
||||||
// hardcoded. The backend stays the authority and re-validates on submit.
|
|
||||||
private policyRes = this.policy.policyResource();
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<IntakeState>(initial);
|
seed = input<IntakeState>(initial);
|
||||||
|
|
||||||
@@ -201,7 +199,7 @@ export class IntakeWizardComponent {
|
|||||||
// only the policy value; untrack the dispatch (it reads the state signal
|
// only the policy value; untrack the dispatch (it reads the state signal
|
||||||
// internally, which would otherwise make this effect loop on its own write).
|
// internally, which would otherwise make this effect loop on its own write).
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
|
const scholingThreshold = this.policyStore.scholingThreshold();
|
||||||
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/app/registratie/application/registratie-lookup.store.ts
Normal file
58
src/app/registratie/application/registratie-lookup.store.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, computed, inject } from '@angular/core';
|
||||||
|
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||||
|
import { DuoLookupDto } from '../contracts/duo-diplomas.dto';
|
||||||
|
import { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter';
|
||||||
|
import { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter';
|
||||||
|
|
||||||
|
type Err = Error | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-layer facade for the registratie wizard's two lookups (BRP address,
|
||||||
|
* DUO diplomas). It owns the httpResources (created here, in the required injection
|
||||||
|
* context), runs the trust-boundary parse, and exposes RemoteData / derived signals
|
||||||
|
* — so the UI reaches the network through application/, never infrastructure/
|
||||||
|
* directly (CLAUDE.md §1: ui → application → domain). Same facade shape as
|
||||||
|
* BigProfileStore.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RegistratieLookupStore {
|
||||||
|
private brp = inject(BrpAdapter);
|
||||||
|
private duo = inject(DuoAdapter);
|
||||||
|
|
||||||
|
private adresRes = this.brp.adresResource();
|
||||||
|
private diplomasRes = this.duo.diplomasResource();
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
readonly 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 address to prefill the draft with, once BRP resolves with a found address;
|
||||||
|
null otherwise (loading, error, no address, malformed). */
|
||||||
|
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {
|
||||||
|
const json = this.adresRes.value();
|
||||||
|
if (json === undefined) return null;
|
||||||
|
const parsed = parseBrpAddress(json);
|
||||||
|
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
|
||||||
|
readonly duoLookup = computed<RemoteData<Err, 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) };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
|
||||||
|
reloadAdres() {
|
||||||
|
this.adresRes.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,8 @@ import { ASYNC } from '@shared/ui/async/async.component';
|
|||||||
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
|
||||||
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
import {
|
import {
|
||||||
RegistratieState,
|
RegistratieState,
|
||||||
@@ -194,8 +193,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RegistratieWizardComponent {
|
export class RegistratieWizardComponent {
|
||||||
private brp = inject(BrpAdapter);
|
private lookup = inject(RegistratieLookupStore);
|
||||||
private duo = inject(DuoAdapter);
|
|
||||||
private uploadAdapter = inject(UploadAdapter);
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||||
|
|
||||||
@@ -204,9 +202,6 @@ export class RegistratieWizardComponent {
|
|||||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||||
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
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. */
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
seed = input<RegistratieState>(initial);
|
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 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']));
|
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
|
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
|
||||||
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
|
served by the application facade — the wizard renders, it does not fetch/parse. */
|
||||||
malformed response; 'fout' is an unreachable BRP. */
|
protected adresStatus = this.lookup.adresStatus;
|
||||||
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
|
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
|
||||||
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) };
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
|
/** 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. */
|
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.
|
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
|
||||||
const seeded = this.seed();
|
const seeded = this.seed();
|
||||||
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
|
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
|
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
|
||||||
// value; untrack the dispatch (it reads the state signal, which would otherwise
|
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
|
||||||
// make this effect loop on its own write). Don't clobber edits/restored data.
|
// 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(() => {
|
effect(() => {
|
||||||
const json = this.adresRes.value();
|
const a = this.lookup.prefillAdres();
|
||||||
if (!json) return;
|
if (!a) return;
|
||||||
const parsed = parseBrpAddress(json);
|
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Invullen' || s.draft.straat) return;
|
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
|
// A11y: focus management (step heading on step change, error summary on a
|
||||||
@@ -400,7 +376,7 @@ export class RegistratieWizardComponent {
|
|||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
|
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
this.adresRes.reload();
|
this.lookup.reloadAdres();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle
|
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle
|
||||||
|
|||||||
Reference in New Issue
Block a user