Step 2 (code quality): dedup + stop FE recomputing a server rule

- 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<U,{tag}>` 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 13:48:35 +02:00
parent 94ffcf3d41
commit 1c65025fef
14 changed files with 118 additions and 68 deletions

View File

@@ -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 { Valid } from '../domain/herregistratie.machine';
import { ApiClient } from '@shared/infrastructure/api-client'; 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`). * 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 * The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced
* ProblemDetails on rejection, which we surface as the error. * as the error by `runSubmit`.
*/ */
export async function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> { export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
try { return runSubmit<void>(async () => {
await client.herregistraties({ uren: data.uren }); await client.herregistraties({ uren: data.uren });
return ok(undefined); }, SUBMIT_FAILED);
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
} }

View File

@@ -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 { ValidIntake } from '../domain/intake.machine';
import { ApiClient } from '@shared/infrastructure/api-client'; 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 * 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 * "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as
* ProblemDetails on rejection, which we surface as the error. * the error by `runSubmit`.
*/ */
export async function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> { export function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
try { return runSubmit<void>(async () => {
await client.intakes({ uren: data.uren }); await client.intakes({ uren: data.uren });
return ok(undefined); }, SUBMIT_FAILED);
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
} }

View File

@@ -5,6 +5,7 @@ import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine'; import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie'; import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
@@ -71,13 +72,13 @@ export class HerregistratieWizardComponent {
readonly stepLabels = ['Werkervaring', 'Nascholing']; readonly stepLabels = ['Werkervaring', 'Nascholing'];
private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing']; private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing'];
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null)); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1); protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }); protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : '')); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);

View File

@@ -8,6 +8,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { import {
IntakeState, IntakeState,
@@ -126,7 +127,7 @@ export class IntakeWizardComponent {
readonly state = this.store.model; readonly state = this.store.model;
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null)); private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */ /** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS; readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0); protected cursor = computed(() => this.answering()?.cursor ?? 0);
@@ -136,7 +137,7 @@ export class IntakeWizardComponent {
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */ /** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : '')); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = ['Buitenland', 'Werk', 'Controle']; readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];

View File

@@ -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 { Valid } from '@registratie/domain/change-request.machine';
import { ApiClient } from '@shared/infrastructure/api-client'; 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`), * 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 * 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. * commands — a `Result`, never a thrown error — so the form's reduce can branch.
*/ */
export async function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> { export function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
try { return runSubmit(async () => {
const res = await client.changeRequests({ const res = await client.changeRequests({
straat: data.straat, straat: data.straat,
postcode: data.postcode, postcode: data.postcode,
woonplaats: data.woonplaats, woonplaats: data.woonplaats,
}); });
return ok(res.referentie ?? ''); return res.referentie ?? '';
} catch (e) { }, SUBMIT_FAILED);
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
} }

View File

@@ -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 { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { ApiClient } from '@shared/infrastructure/api-client'; 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`), * Command: POST the completed registration to the backend (`/api/registrations`),
* which re-validates and decides. The business rule that a manually entered * which re-validates and decides. The rule that a manually entered diploma cannot
* diploma cannot be auto-verified now lives server-side; the backend returns it as * be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On
* a 422 ProblemDetails, which we surface as the error. On success it yields the * success it yields the server-generated confirmation reference (PRD §9).
* server-generated confirmation reference (PRD §9).
*/ */
export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> { export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
try { return runSubmit(async () => {
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst }); const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
return ok(res.referentie ?? ''); return res.referentie ?? '';
} catch (e) { }, SUBMIT_FAILED);
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
} }

View File

@@ -12,21 +12,20 @@ const base: Registration = {
}; };
describe('tasksFromProfile', () => { describe('tasksFromProfile', () => {
it('offers herregistratie when within the deadline window', () => { it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
const tasks = tasksFromProfile(base, new Date('2026-06-27')); const tasks = tasksFromProfile(base, true);
expect(tasks).toHaveLength(1); expect(tasks).toHaveLength(1);
expect(tasks[0].to).toBe('/herregistratie'); expect(tasks[0].to).toBe('/herregistratie');
expect(tasks[0].description).toContain('31 december 2026'); expect(tasks[0].description).toContain('31 december 2026');
}); });
it('offers nothing when the deadline is far away', () => { it('offers nothing when the server says not eligible', () => {
const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } }; expect(tasksFromProfile(base, false)).toHaveLength(0);
expect(tasksFromProfile(far, new Date('2026-06-27'))).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 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).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst'); expect(tasks[0].title).toContain('geschorst');
expect(tasks[0].description).toBe('Onderzoek'); expect(tasks[0].description).toBe('Onderzoek');
@@ -34,7 +33,7 @@ describe('tasksFromProfile', () => {
it('surfaces a notice for a struck-off registration', () => { 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 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).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald'); expect(tasks[0].title).toContain('doorgehaald');
}); });

View File

@@ -1,5 +1,5 @@
import { Registration } from './registration'; 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 * 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; * Derive the open tasks for a professional (pure). Eligibility is the server's
* `today` is injected so it's testable). Herregistratie within its window is the * decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
* primary action; a suspended/struck-off registration surfaces as a notice-task. * it does not recompute the rule (ADR-0001). The deadline is still formatted
* Reuses the same rules the detail/summary use (registration.policy). * 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[] = []; const tasks: PortalTask[] = [];
if (isHerregistratieEligible(reg, today)) { if (eligibleForHerregistratie) {
const deadline = herregistratieDeadline(reg); const deadline = herregistratieDeadline(reg);
tasks.push({ tasks.push({
title: 'Vraag uw herregistratie aan', title: 'Vraag uw herregistratie aan',

View File

@@ -4,6 +4,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component'; import { AddressFieldsComponent, AdresValue, AdresErrors } 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 { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
import { submitChangeRequest } from '@registratie/application/submit-change-request'; import { submitChangeRequest } from '@registratie/application/submit-change-request';
import { ApiClient } from '@shared/infrastructure/api-client'; import { ApiClient } from '@shared/infrastructure/api-client';
@@ -55,10 +56,10 @@ export class ChangeRequestFormComponent {
readonly state = this.store.model; readonly state = this.store.model;
protected dispatch = this.store.dispatch; protected dispatch = this.store.dispatch;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<State, { tag: 'Editing' }>) : null)); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {}); protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<State, { tag: 'Failed' }>).error : '')); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract<State, { tag: 'Submitted' }>).referentie : '')); protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');
/** The address shown in the fields — the live draft while editing, the parsed /** The address shown in the fields — the live draft while editing, the parsed
data while submitting/failed (so the user sees what they sent). */ data while submitting/failed (so the user sees what they sent). */

View File

@@ -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 { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component'; import { LinkComponent } from '@shared/ui/link/link.component';
@@ -103,10 +103,15 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
}) })
export class DashboardPage { export class DashboardPage {
protected store = inject(BigProfileStore); 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) { protected tasksFor(reg: Registration) {
return tasksFromProfile(reg, this.today); return tasksFromProfile(reg, this.eligible());
} }
/** Portal sections (navigation, not actions). */ /** Portal sections (navigation, not actions). */

View File

@@ -11,6 +11,7 @@ import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/
import { ASYNC } from '@shared/ui/async/async.component'; 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 { RemoteData, fromResource } from '@shared/application/remote-data'; import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter'; import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter'; import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
@@ -180,13 +181,13 @@ export class RegistratieWizardComponent {
readonly state = this.store.model; readonly state = this.store.model;
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null)); private invullen = computed(() => whenTag(this.state(), 'Invullen'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} }); protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.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<RegistratieState, { tag: 'Ingediend' }>).referentie : '')); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : '')); protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende')); protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));

View File

@@ -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' });
});
});

View File

@@ -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<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
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.';

View File

@@ -21,3 +21,13 @@ export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string /** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
only through an explicit cast — so a smart constructor is the only minter. */ only through an explicit cast — so a smart constructor is the only minter. */
export type Brand<T, B extends string> = T & { readonly __brand: B }; export type Brand<T, B extends string> = 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 extends { tag: string }, K extends U['tag']>(
u: U,
tag: K,
): Extract<U, { tag: K }> | null {
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
}