diff --git a/src/app/registratie/application/draft-sync.ts b/src/app/registratie/application/draft-sync.ts new file mode 100644 index 0000000..ffa5fe2 --- /dev/null +++ b/src/app/registratie/application/draft-sync.ts @@ -0,0 +1,105 @@ +import { DestroyRef, effect, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AanvraagType } from '@registratie/domain/aanvraag'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; + +/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ +export interface DraftSnapshot { + draft: unknown; + stepIndex: number; + stepCount: number; + documentIds: string[]; +} + +export interface DraftSyncDeps { + type: AanvraagType; + /** The machine snapshot while it's worth persisting; null when not (pristine/done). */ + snapshot: () => DraftSnapshot | null; + /** Seed the machine from a resumed draft (null → start fresh). Called once, on init. */ + onResume: (draft: unknown | null) => void; + /** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */ + enabled: () => boolean; +} + +const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty. + +/** + * The effectful glue that replaces per-wizard sessionStorage with a backend-owned + * Concept (PRD 0001, phase D). Instantiated in a field initializer (like + * `createStore`/`createUploadController`). Responsibilities: + * + * - resume: if the URL carries `?aanvraag=`, load that draft and seed the machine; + * - create-on-first-progress: the Concept is created lazily the first time the wizard + * reports a non-null snapshot, and the id is stamped into the URL (so a reload resumes); + * - debounced draft sync on every subsequent change. + * + * Inert without a Router (stories) or when `enabled()` is false — no network, no resume. + */ +export function createDraftSync(deps: DraftSyncDeps) { + const adapter = inject(ApplicationsAdapter); + const router = inject(Router, { optional: true }); + const route = inject(ActivatedRoute, { optional: true }); + const active = () => deps.enabled() && !!router && !!route; + + let id: string | undefined; + let ensuring: Promise | undefined; // in-flight create, so we never create twice + let timer: ReturnType | undefined; + + const ensureId = (): Promise => { + if (id) return Promise.resolve(id); + ensuring ??= adapter.create(deps.type).then((newId) => { + id = newId; + // Stamp the id into the URL (no navigation) so a reload resumes this Concept. + void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: newId }, queryParamsHandling: 'merge', replaceUrl: true }); + return newId; + }); + return ensuring; + }; + + const flush = async () => { + const snap = deps.snapshot(); + if (!snap) return; + const theId = await ensureId(); + await adapter.syncDraft(theId, { draft: snap.draft, stepIndex: snap.stepIndex, stepCount: snap.stepCount, documentIds: snap.documentIds }); + }; + + // One effect watches the snapshot; each change resets a debounce timer. The timer's + // callback only does network I/O (never dispatch), so it can't livelock the store. + effect(() => { + if (!active()) return; + const snap = deps.snapshot(); // tracked: fires on every machine change + if (!snap) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => void flush(), DEBOUNCE_MS); + }); + + inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer)); + + return { + /** Resolve the initial state: resume a linked Concept, or start fresh. */ + resume() { + if (!active()) { + deps.onResume(null); + return; + } + const linked = route!.snapshot.queryParamMap.get('aanvraag'); + if (!linked) { + deps.onResume(null); + return; + } + id = linked; + adapter + .detail(linked) + .then((dto) => deps.onResume(dto.draft ?? null)) + .catch(() => deps.onResume(null)); // unknown/deleted id → start fresh + }, + + /** Detach from the current Concept (a new one is created on next progress) and + drop the `?aanvraag` link. Used when the wizard restarts. */ + reset() { + id = undefined; + ensuring = undefined; + if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true }); + }, + }; +} diff --git a/src/app/registratie/domain/has-progress.spec.ts b/src/app/registratie/domain/has-progress.spec.ts new file mode 100644 index 0000000..e82cace --- /dev/null +++ b/src/app/registratie/domain/has-progress.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine'; + +const invullen = (over: Partial>) => + ({ ...(initial as Extract), ...over }); + +describe('hasProgress', () => { + it('is false for a fresh wizard', () => { + expect(hasProgress(initial as Extract)).toBe(false); + }); + + it('ignores an auto-prefilled BRP address at step 0', () => { + const s = invullen({ draft: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', antwoorden: {} } }); + expect(hasProgress(s)).toBe(false); + }); + + it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => { + expect(hasProgress(invullen({ cursor: 1 }))).toBe(true); + expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true); + expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true); + }); +}); diff --git a/src/app/registratie/domain/registratie-wizard.machine.ts b/src/app/registratie/domain/registratie-wizard.machine.ts index d625e5e..a4d6245 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.ts @@ -91,6 +91,22 @@ export function currentStep(s: Extract): return STEPS[Math.min(s.cursor, STEPS.length - 1)]; } +/** Has the user meaningfully started, so it's worth persisting as a Concept? Excludes + the automatic BRP address prefill on step 0 — a bare page visit creates nothing. + ponytail: an address typed at step 0 without any of these signals is not yet + persisted (created once they advance/choose); accepted regression vs. sessionStorage. */ +export function hasProgress(s: Extract): boolean { + const d = s.draft; + return ( + s.cursor > 0 || + !!d.correspondentie || + !!d.email || + !!d.diplomaId || + !!d.beroep || + deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) + ); +} + /** Validate every question currently visible in ONE step. Errors keyed per field. */ function validateStep(step: StepId, d: Draft, upload: UploadState): Result { const errors: Errors = {}; 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 081a469..511a431 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -25,15 +25,16 @@ import { StepId, initial, reduce, + hasProgress, STEPS, } from '@registratie/domain/registratie-wizard.machine'; import { submitRegistratie } from '@registratie/application/submit-registratie'; +import { createDraftSync } from '@registratie/application/draft-sync'; import { ApiClient } from '@shared/infrastructure/api-client'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadState, initialUpload } from '@shared/upload/upload.machine'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; -const STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration. const KANALEN = [ { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, { value: 'post', label: $localize`:@@registratie.kanaalPost:Post` }, @@ -43,8 +44,9 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" /** Organism: the BIG-registration wizard. All state lives in one signal driven by the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the draft via an effect; the DUO diploma list renders through ; choosing - a diploma reveals its server-derived beroep. The draft is persisted to - sessionStorage so a reload keeps the user's progress (cleared on tab close). Built from existing + a diploma reveals its server-derived beroep. The draft is persisted to the + backend as a Concept aanvraag (createDraftSync) so a reload — or a "Verder gaan" + from the dashboard via `?aanvraag=` — resumes progress. Built from existing atoms/molecules. */ @Component({ selector: 'app-registratie-wizard', @@ -207,6 +209,19 @@ export class RegistratieWizardComponent { getUpload: () => this.upload(), dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), }); + // Backend draft-sync (replaces sessionStorage): create a Concept once the user has + // made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`. + private draftSync = createDraftSync({ + type: 'registratie', + snapshot: () => { + const s = this.state(); + if (s.tag !== 'Invullen' || !hasProgress(s)) return null; + const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!); + return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds }; + }, + onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as RegistratieState | null) ?? initial }), + enabled: () => this.seed() === initial, + }); 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(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); @@ -315,16 +330,10 @@ export class RegistratieWizardComponent { } constructor() { - // An explicit seed (stories) wins; otherwise resume from sessionStorage. + // An explicit seed (stories/tests) wins; otherwise resume from the backend draft + // (`?aanvraag=`), or start fresh. Persistence is the draftSync controller's job. const seeded = this.seed(); - queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) })); - // Persist only while filling in; clear once the flow is done. G1: sessionStorage - // (not localStorage) — the draft holds address/email and must not outlive the tab. - effect(() => { - const s = this.state(); - if (s.tag === 'Invullen') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); - else sessionStorage.removeItem(STORAGE_KEY); - }); + 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 // value; untrack the dispatch (it reads the state signal, which would otherwise // make this effect loop on its own write). Don't clobber edits/restored data. @@ -346,17 +355,6 @@ export class RegistratieWizardComponent { // failed submit) now lives in the shared WizardShellComponent. } - private restore(): RegistratieState | null { - const raw = sessionStorage.getItem(STORAGE_KEY); - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as RegistratieState; - return parsed?.tag === 'Invullen' ? parsed : null; // G2: only resume a known shape. - } catch { - return null; // ponytail: corrupt entry -> start fresh, no migration. - } - } - onPrimary() { const s = this.state(); if (s.tag !== 'Invullen') return; @@ -372,6 +370,7 @@ export class RegistratieWizardComponent { /** Reset the wizard to a fresh start. Reload the BRP lookup so the address re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */ restart() { + this.draftSync.reset(); // detach from the old Concept; next progress creates a new one this.dispatch({ tag: 'Seed', state: initial }); this.adresRes.reload(); }