Files
atomic-design-poc/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts
T
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00

187 lines
6.4 KiB
TypeScript

import { Result, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
import {
UploadState,
UploadMsg,
initialUpload,
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
uren: string;
jaren: string;
punten: string;
}
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
export interface Valid {
uren: Uren;
jaren: number;
punten: number;
documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;
}
/**
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
* So "submitting while a field is invalid" or "showing the success screen with
* errors set" are unrepresentable — the bug class is gone by construction.
*/
export type WizardState =
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = {
tag: 'Editing',
step: 1,
draft: { uren: '', jaren: '', punten: '' },
errors: {},
upload: initialUpload,
};
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
return (
s.step > 1 ||
!!s.draft.uren ||
!!s.draft.jaren ||
!!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
);
}
/** Parse every field; on success hand back a Valid, else the per-field errors. */
function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid> {
const uren = parseUren(draft.uren);
const jaren = parseUren(draft.jaren);
const punten = parseUren(draft.punten);
const errors: StepErrors = {};
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
if (!punten.ok) errors.punten = punten.error;
if (!requiredCategoriesSatisfied(upload)) {
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
}
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
return {
ok: true,
value: {
uren: uren.value,
jaren: jaren.value,
punten: punten.value,
documents: deliveryRefs(upload),
},
};
}
return { ok: false, error: errors };
}
/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */
export function next(s: WizardState): WizardState {
if (s.tag !== 'Editing') return s;
const errors: StepErrors = {};
if (s.step === 1) {
const uren = parseUren(s.draft.uren);
const jaren = parseUren(s.draft.jaren);
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
}
if (s.step === 2) {
const punten = parseUren(s.draft.punten);
if (!punten.ok) errors.punten = punten.error;
return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };
}
return s;
}
export function back(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step === 1) return s;
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
}
/** Jump back to an earlier step to correct data (controle → step N). Forward
jumps are not allowed (would skip validation). */
export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {
if (s.tag !== 'Editing' || step >= s.step) return s;
return { ...s, step, errors: {} };
}
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 3) return s;
const result = validate(s.draft, s.upload);
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, upload: reduceUpload(s.upload, msg) };
}
/** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s;
return r.ok
? { tag: 'Submitted', data: s.data }
: { tag: 'Failed', data: s.data, error: r.error };
}
/** Update one draft field while editing; ignored in any other state. */
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, draft: { ...s.draft, [key]: value } };
}
/**
* Every event that can happen to the wizard, as one message type. The component
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
* Model+Msg+update pattern: ONE pure function describes all state changes.
*/
export type WizardMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
export function reduce(s: WizardState, m: WizardMsg): WizardState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.step);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Upload':
return upload(s, m.msg);
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}