Architect-review remediation: enforce conventions, prod-safe tooling, one form idiom, resilience seams
Acts on the showcase review. Four workstreams; all tests green (npm run lint, 70 FE tests, ng build, 33 backend tests). Enforcement + CI: - eslint.config.mjs bans `any` and enforces layer/context boundaries (domain ≠ Angular; herregistratie → registratie → shared, auth → shared); `npm run lint` added; ajv 6 scoped to ESLint via nested override. - .github/workflows/ci.yml: FE lint+check:tokens+test+build, backend dotnet test, and an API-client drift check. One form idiom (the headline finding): - change-request-form converged onto the wizard pattern — change-request.machine.ts (Model/Msg/reduce + value objects) + submit-change-request.ts (Result) + a real POST /api/v1/change-requests (server re-validates). Spec + story added; the detail page no longer holds an ad-hoc success signal. Resilience/observability seam: - api-client.provider.ts: request timeout, X-Correlation-Id, Idempotency-Key for writes; comments naming the retry/auth seams. - Backend logs correlation id + a no-PII submit-audit line; /api/v1 prefix + backward-compat note; client regenerated. Quick wins: - Dev tooling excluded from prod: scenario.interceptor wired only under isDevMode() (?scenario= inert in prod); debug panel @if(isDev) (tree-shaken out). - src/environments + apiBaseUrl into provideApiClient (angular.json fileReplacements). - Backend /health + /health/ready. - Debug view PII-minimised (redactProfile: name/address/DOB redacted, BIG masked). - IntakePolicyAdapter (removes inline resource in the intake wizard). - README de-staled; CLAUDE.md gains EN/NL + forms-one-idiom + lint/CI notes. - Stories: text-input, link, data-row, site-header, site-footer, change-request-form. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
82
src/app/registratie/domain/change-request.machine.ts
Normal file
82
src/app/registratie/domain/change-request.machine.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
straat: string;
|
||||
postcode: string;
|
||||
woonplaats: string;
|
||||
}
|
||||
|
||||
/** After parsing — postcode is the branded type, so downstream can't get a raw one. */
|
||||
export interface Valid {
|
||||
straat: string;
|
||||
postcode: Postcode;
|
||||
woonplaats: string;
|
||||
}
|
||||
|
||||
export type Errors = Partial<Record<keyof Draft, string>>;
|
||||
|
||||
/**
|
||||
* The change-request (adreswijziging) form as one tagged union — the SAME idiom
|
||||
* as the wizards, just single-step. `draft`/`errors` exist only while Editing;
|
||||
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
|
||||
* an invalid draft, a success screen with errors) are unrepresentable.
|
||||
*/
|
||||
export type State =
|
||||
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid; referentie: string }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export const initial: State = {
|
||||
tag: 'Editing',
|
||||
draft: { straat: '', postcode: '', woonplaats: '' },
|
||||
errors: {},
|
||||
};
|
||||
|
||||
/** Parse via the value objects; on success hand back a Valid, else per-field errors. */
|
||||
function validate(draft: Draft): Result<Errors, Valid> {
|
||||
const straat = draft.straat.trim();
|
||||
const postcode = parsePostcode(draft.postcode);
|
||||
const errors: Errors = {};
|
||||
if (!straat) errors.straat = 'Vul straat en huisnummer in.';
|
||||
if (!postcode.ok) errors.postcode = postcode.error;
|
||||
if (straat && postcode.ok) {
|
||||
return { ok: true, value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() } };
|
||||
}
|
||||
return { ok: false, error: errors };
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Reset' }
|
||||
| { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)
|
||||
|
||||
export function reduce(s: State, m: Msg): State {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
|
||||
case 'Submit': {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
const r = validate(s.draft);
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Reset':
|
||||
return initial;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user