From 1c65025fef4a413d28176b1867827f41bcb31196 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 27 Jun 2026 13:48:35 +0200 Subject: [PATCH] Step 2 (code quality): dedup + stop FE recomputing a server rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H1: tasksFromProfile takes the server's eligibleForHerregistratie decision instead of recomputing isHerregistratieEligible — the FE renders the rule, doesn't own it (ADR-0001). Policy reference impl kept for tests. - M1: one shared runSubmit(fn, fallback) wrapper; the 4 submit-* commands keep only their payload mapping. +spec. - M2: whenTag() kernel helper removes 10 repeated `as Extract` casts across the wizard/form components. M4 (shared JA_NEE) folded into the upcoming i18n pass (clean dedup needs $localize labels to sit in shared without breaking the English-shared-UI rule). L1 already resolved by the restyle commit. Co-Authored-By: Claude Opus 4.8 --- .../application/submit-herregistratie.ts | 17 ++++++-------- .../application/submit-intake.ts | 17 ++++++-------- .../herregistratie-wizard.component.ts | 5 ++-- .../intake-wizard/intake-wizard.component.ts | 5 ++-- .../application/submit-change-request.ts | 14 +++++------ .../application/submit-registratie.ts | 21 ++++++++--------- src/app/registratie/domain/tasks.spec.ts | 15 ++++++------ src/app/registratie/domain/tasks.ts | 14 +++++------ .../change-request-form.component.ts | 7 +++--- src/app/registratie/ui/dashboard.page.ts | 11 ++++++--- .../registratie-wizard.component.ts | 7 +++--- src/app/shared/application/submit.spec.ts | 23 +++++++++++++++++++ src/app/shared/application/submit.ts | 20 ++++++++++++++++ src/app/shared/kernel/fp.ts | 10 ++++++++ 14 files changed, 118 insertions(+), 68 deletions(-) create mode 100644 src/app/shared/application/submit.spec.ts create mode 100644 src/app/shared/application/submit.ts diff --git a/src/app/herregistratie/application/submit-herregistratie.ts b/src/app/herregistratie/application/submit-herregistratie.ts index bc8aef5..c82dbc7 100644 --- a/src/app/herregistratie/application/submit-herregistratie.ts +++ b/src/app/herregistratie/application/submit-herregistratie.ts @@ -1,18 +1,15 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { Valid } from '../domain/herregistratie.machine'; import { ApiClient } from '@shared/infrastructure/api-client'; -import { problemDetail } from '@shared/infrastructure/api-error'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** * Command: POST a herregistratie application to the backend (`/api/herregistraties`). - * The "uren must be > 0" rule now lives server-side; the backend returns a 422 - * ProblemDetails on rejection, which we surface as the error. + * The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced + * as the error by `runSubmit`. */ -export async function submitHerregistratie(client: ApiClient, data: Valid): Promise> { - try { +export function submitHerregistratie(client: ApiClient, data: Valid): Promise> { + return runSubmit(async () => { await client.herregistraties({ uren: data.uren }); - return ok(undefined); - } catch (e) { - return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); - } + }, SUBMIT_FAILED); } diff --git a/src/app/herregistratie/application/submit-intake.ts b/src/app/herregistratie/application/submit-intake.ts index fb28403..857b292 100644 --- a/src/app/herregistratie/application/submit-intake.ts +++ b/src/app/herregistratie/application/submit-intake.ts @@ -1,18 +1,15 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { ValidIntake } from '../domain/intake.machine'; import { ApiClient } from '@shared/infrastructure/api-client'; -import { problemDetail } from '@shared/infrastructure/api-error'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** * Command: POST the intake questionnaire to the backend (`/api/intakes`). The - * "uren must be > 0" rule now lives server-side; the backend returns a 422 - * ProblemDetails on rejection, which we surface as the error. + * "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as + * the error by `runSubmit`. */ -export async function submitIntake(client: ApiClient, data: ValidIntake): Promise> { - try { +export function submitIntake(client: ApiClient, data: ValidIntake): Promise> { + return runSubmit(async () => { await client.intakes({ uren: data.uren }); - return ok(undefined); - } catch (e) { - return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); - } + }, SUBMIT_FAILED); } diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 46855c8..acfdd7c 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -5,6 +5,7 @@ import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine'; import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie'; @@ -71,13 +72,13 @@ export class HerregistratieWizardComponent { readonly stepLabels = ['Werkervaring', 'Nascholing']; private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing']; - private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null)); + private editing = computed(() => whenTag(this.state(), 'Editing')); protected step = computed(() => this.editing()?.step ?? 1); protected draft = computed(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }); protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); - protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 07b1756..e1dc727 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -8,6 +8,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { IntakeState, @@ -126,7 +127,7 @@ export class IntakeWizardComponent { readonly state = this.store.model; readonly dispatch = this.store.dispatch; - private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract) : null)); + private answering = computed(() => whenTag(this.state(), 'Answering')); /** Public so the showcase can render the (fixed) step list next to the wizard. */ readonly steps = STEPS; protected cursor = computed(() => this.answering()?.cursor ?? 0); @@ -136,7 +137,7 @@ export class IntakeWizardComponent { protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); /** Whether the inline scholing question is shown (and required) in the 'werk' step. */ protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); - protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- readonly stepLabels = ['Buitenland', 'Werk', 'Controle']; diff --git a/src/app/registratie/application/submit-change-request.ts b/src/app/registratie/application/submit-change-request.ts index dd20bdc..02ae11c 100644 --- a/src/app/registratie/application/submit-change-request.ts +++ b/src/app/registratie/application/submit-change-request.ts @@ -1,22 +1,20 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { Valid } from '@registratie/domain/change-request.machine'; import { ApiClient } from '@shared/infrastructure/api-client'; -import { problemDetail } from '@shared/infrastructure/api-error'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** * Command: POST an address change to the backend (`/api/v1/change-requests`), * which re-validates and returns a reference. Same shape as the other submit * commands — a `Result`, never a thrown error — so the form's reduce can branch. */ -export async function submitChangeRequest(client: ApiClient, data: Valid): Promise> { - try { +export function submitChangeRequest(client: ApiClient, data: Valid): Promise> { + return runSubmit(async () => { const res = await client.changeRequests({ straat: data.straat, postcode: data.postcode, woonplaats: data.woonplaats, }); - return ok(res.referentie ?? ''); - } catch (e) { - return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); - } + return res.referentie ?? ''; + }, SUBMIT_FAILED); } diff --git a/src/app/registratie/application/submit-registratie.ts b/src/app/registratie/application/submit-registratie.ts index 40183f4..79f89be 100644 --- a/src/app/registratie/application/submit-registratie.ts +++ b/src/app/registratie/application/submit-registratie.ts @@ -1,20 +1,17 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; import { ApiClient } from '@shared/infrastructure/api-client'; -import { problemDetail } from '@shared/infrastructure/api-error'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** * Command: POST the completed registration to the backend (`/api/registrations`), - * which re-validates and decides. The business rule that a manually entered - * diploma cannot be auto-verified now lives server-side; the backend returns it as - * a 422 ProblemDetails, which we surface as the error. On success it yields the - * server-generated confirmation reference (PRD §9). + * which re-validates and decides. The rule that a manually entered diploma cannot + * be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On + * success it yields the server-generated confirmation reference (PRD §9). */ -export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise> { - try { +export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise> { + return runSubmit(async () => { const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst }); - return ok(res.referentie ?? ''); - } catch (e) { - return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); - } + return res.referentie ?? ''; + }, SUBMIT_FAILED); } diff --git a/src/app/registratie/domain/tasks.spec.ts b/src/app/registratie/domain/tasks.spec.ts index ea28fd7..316d13a 100644 --- a/src/app/registratie/domain/tasks.spec.ts +++ b/src/app/registratie/domain/tasks.spec.ts @@ -12,21 +12,20 @@ const base: Registration = { }; describe('tasksFromProfile', () => { - it('offers herregistratie when within the deadline window', () => { - const tasks = tasksFromProfile(base, new Date('2026-06-27')); + it('offers herregistratie when the server says eligible, with the formatted deadline', () => { + const tasks = tasksFromProfile(base, true); expect(tasks).toHaveLength(1); expect(tasks[0].to).toBe('/herregistratie'); expect(tasks[0].description).toContain('31 december 2026'); }); - it('offers nothing when the deadline is far away', () => { - const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } }; - expect(tasksFromProfile(far, new Date('2026-06-27'))).toHaveLength(0); + it('offers nothing when the server says not eligible', () => { + expect(tasksFromProfile(base, false)).toHaveLength(0); }); - it('surfaces a notice for a suspended registration', () => { + it('surfaces a notice for a suspended registration (independent of eligibility)', () => { const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } }; - const tasks = tasksFromProfile(reg, new Date('2026-06-27')); + const tasks = tasksFromProfile(reg, false); expect(tasks).toHaveLength(1); expect(tasks[0].title).toContain('geschorst'); expect(tasks[0].description).toBe('Onderzoek'); @@ -34,7 +33,7 @@ describe('tasksFromProfile', () => { it('surfaces a notice for a struck-off registration', () => { const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } }; - const tasks = tasksFromProfile(reg, new Date('2026-06-27')); + const tasks = tasksFromProfile(reg, false); expect(tasks).toHaveLength(1); expect(tasks[0].title).toContain('doorgehaald'); }); diff --git a/src/app/registratie/domain/tasks.ts b/src/app/registratie/domain/tasks.ts index bf270aa..01b652d 100644 --- a/src/app/registratie/domain/tasks.ts +++ b/src/app/registratie/domain/tasks.ts @@ -1,5 +1,5 @@ import { Registration } from './registration'; -import { herregistratieDeadline, isHerregistratieEligible } from './registration.policy'; +import { herregistratieDeadline } from './registration.policy'; /** * What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data @@ -17,15 +17,15 @@ function formatNL(d: Date): string { } /** - * Derive the open tasks for a professional from their registration (pure; - * `today` is injected so it's testable). Herregistratie within its window is the - * primary action; a suspended/struck-off registration surfaces as a notice-task. - * Reuses the same rules the detail/summary use (registration.policy). + * Derive the open tasks for a professional (pure). Eligibility is the server's + * decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it, + * it does not recompute the rule (ADR-0001). The deadline is still formatted + * client-side for the task copy (presentation, not a rule). */ -export function tasksFromProfile(reg: Registration, today: Date): PortalTask[] { +export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] { const tasks: PortalTask[] = []; - if (isHerregistratieEligible(reg, today)) { + if (eligibleForHerregistratie) { const deadline = herregistratieDeadline(reg); tasks.push({ title: 'Vraag uw herregistratie aan', diff --git a/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/src/app/registratie/ui/change-request-form/change-request-form.component.ts index e8816d1..3e132ad 100644 --- a/src/app/registratie/ui/change-request-form/change-request-form.component.ts +++ b/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -4,6 +4,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; import { submitChangeRequest } from '@registratie/application/submit-change-request'; import { ApiClient } from '@shared/infrastructure/api-client'; @@ -55,10 +56,10 @@ export class ChangeRequestFormComponent { readonly state = this.store.model; protected dispatch = this.store.dispatch; - private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null)); + private editing = computed(() => whenTag(this.state(), 'Editing')); protected errors = computed(() => this.editing()?.errors ?? {}); - protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); - protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract).referentie : '')); + protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); + protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? ''); /** The address shown in the fields — the live draft while editing, the parsed data while submitting/failed (so the user sees what they sent). */ diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index 480e6d2..875f8fd 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { LinkComponent } from '@shared/ui/link/link.component'; @@ -103,10 +103,15 @@ import { tasksFromProfile } from '@registratie/domain/tasks'; }) export class DashboardPage { protected store = inject(BigProfileStore); - private readonly today = new Date(); + + /** Server-computed eligibility (rendered, not recomputed). */ + private readonly eligible = computed(() => { + const d = this.store.decisions(); + return d.tag === 'Success' && d.value.eligibleForHerregistratie; + }); protected tasksFor(reg: Registration) { - return tasksFromProfile(reg, this.today); + return tasksFromProfile(reg, this.eligible()); } /** Portal sections (navigation, not actions). */ diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 00563eb..11eafdb 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -11,6 +11,7 @@ import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/ 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'; @@ -180,13 +181,13 @@ export class RegistratieWizardComponent { readonly state = this.store.model; readonly dispatch = this.store.dispatch; - private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract) : null)); + private invullen = computed(() => whenTag(this.state(), 'Invullen')); protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} }); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]); - protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract).referentie : '')); - protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract).error : '')); + protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); + protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende')); diff --git a/src/app/shared/application/submit.spec.ts b/src/app/shared/application/submit.spec.ts new file mode 100644 index 0000000..32abe86 --- /dev/null +++ b/src/app/shared/application/submit.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { runSubmit } from './submit'; + +describe('runSubmit', () => { + it('folds a resolved call into ok(value)', async () => { + const r = await runSubmit(async () => 'BIG-123', 'fallback'); + expect(r).toEqual({ ok: true, value: 'BIG-123' }); + }); + + it('maps a ProblemDetails rejection to err(detail)', async () => { + const r = await runSubmit(async () => { + throw { detail: 'Aanvraag afgewezen.' }; + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' }); + }); + + it('falls back when the rejection has no detail', async () => { + const r = await runSubmit(async () => { + throw new Error('network'); + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'fallback' }); + }); +}); diff --git a/src/app/shared/application/submit.ts b/src/app/shared/application/submit.ts new file mode 100644 index 0000000..041086f --- /dev/null +++ b/src/app/shared/application/submit.ts @@ -0,0 +1,20 @@ +import { Result, ok, err } from '@shared/kernel/fp'; +import { problemDetail } from '@shared/infrastructure/api-error'; + +/** + * Run a mutating API call and fold it into a `Result` — the one place the + * try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just + * its own payload mapping. The backend re-validates and returns a 422 + * ProblemDetails on rejection, surfaced here as the error string. + */ +export async function runSubmit(fn: () => Promise, fallback: string): Promise> { + try { + return ok(await fn()); + } catch (e) { + return err(problemDetail(e, fallback)); + } +} + +// ponytail: i18n in Step 3/M3 wraps this in $localize with a stable @@id, which +// dedupes it at the translation layer; for now it's the single shared default. +export const SUBMIT_FAILED = 'Het indienen is niet gelukt. Probeer het later opnieuw.'; diff --git a/src/app/shared/kernel/fp.ts b/src/app/shared/kernel/fp.ts index 872f495..6dbe5dc 100644 --- a/src/app/shared/kernel/fp.ts +++ b/src/app/shared/kernel/fp.ts @@ -21,3 +21,13 @@ export const err = (error: E): Result => ({ ok: false, error }); /** Nominal typing: Brand is assignable from a plain string only through an explicit cast — so a smart constructor is the only minter. */ export type Brand = T & { readonly __brand: B }; + +/** Narrow a tagged union to one variant by its `tag`, or null. The single place + the cast lives — TS can't narrow through a runtime tag argument, so callers get + `whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */ +export function whenTag( + u: U, + tag: K, +): Extract | null { + return u.tag === tag ? (u as Extract) : null; +}