import { DestroyRef, effect, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Result } from '@shared/kernel/fp'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { registerPendingSave } from '@shared/application/pending-saves'; import type { AanvraagIndienenRequest, AanvraagIndienenResponse, } from '@shared/infrastructure/api-client'; import { AanvraagType } from '@registratie/domain/aanvraag'; import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { findConcept, loadConcept } from './find-concept'; /** 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. Called at most once, on init, and ONLY with a real draft on a still-pristine machine — see `applyResume`. */ onResume: (draft: unknown) => 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: a `?aanvraag=` link wins; otherwise resume the ONE existing Concept of * this type (at most one per type), seeding the machine from its saved draft; * - create-on-first-progress: when no Concept exists, one is created lazily the first * time the wizard reports a non-null snapshot, and its id is stamped into the URL; * - 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(AanvragenAdapter); 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; // Resolves once resume() has decided whether a Concept of this type already exists; // gates ensureId so a fast typist can't create a duplicate before that lookup lands. let resumeGate: Promise = Promise.resolve(); const ensureId = async (): Promise => { await resumeGate; if (id) return id; ensuring ??= adapter .create(deps.type) // One Concept per type is server-enforced. Within a tab the resumeGate // already prevents a second create, but a cross-tab/stale race can still hit the // server's guard (409) — recover by adopting the existing Concept instead of // erroring. Only recover when one actually exists; otherwise surface the failure. .catch(async (e) => { const existing = await findConcept(adapter, deps.type); if (existing) return existing; throw e; }) .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; }; // Apply a resumed draft only when it's safe to: a late lookup must never clobber // progress the user already made while it was in flight, and "start fresh" needs no // dispatch (the machine already starts fresh). snapshot() is non-null once the user // has real progress. const applyResume = (draft: unknown | null) => { if (draft == null || deps.snapshot() != null) return; deps.onResume(draft); }; 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); // Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed". timer = setTimeout(() => { timer = undefined; void flush(); }, DEBOUNCE_MS); }); inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer)); // Flush a pending debounced draft write before an in-app route change / unload (see // pending-saves.ts). onDestroy above only cancels the timer — this actually persists it. const hasPendingSave = () => timer !== undefined; const flushPending = async () => { if (timer === undefined) return; clearTimeout(timer); timer = undefined; await flush(); }; registerPendingSave({ hasPendingSave, flushPending }); // Attach to a specific Concept id and seed the machine from its draft. A non-Concept // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft. const load = (linked: string): Promise => { id = linked; return loadConcept(adapter, linked).then((result) => { if (result.tag === 'not-concept') { id = undefined; applyResume(null); return; } applyResume(result.draft); }); }; return { /** True while a debounced draft write is still pending (PendingSave). */ hasPendingSave, /** Flush the pending draft write now and await it; no-op when nothing is pending. */ flushPending, /** Resolve the initial state: a `?aanvraag` link wins; else resume this type's existing Concept; else start fresh (a Concept is created on first progress). */ async resume() { let release!: () => void; resumeGate = new Promise((r) => (release = r)); try { if (!active()) { applyResume(null); return; } const linked = route!.snapshot.queryParamMap.get('aanvraag'); if (linked) { await load(linked); return; } const existing = await findConcept(adapter, deps.type); if (existing) { await load(existing); // Stamp the id into the URL so a reload resumes the same Concept. void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: existing }, queryParamsHandling: 'merge', replaceUrl: true, }); return; } applyResume(null); } finally { release(); } }, /** Submit through the aanvraag lifecycle: ensure the Concept exists, then `POST /applications/{id}/submit` (server sets autoApprovable + transitions). Folded into a Result like the old submit-* commands. */ submit(body: AanvraagIndienenRequest): Promise> { return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED); }, /** Restart: discard the current in-progress Concept (delete it) and detach, so a fresh one is created on next progress. Keeps the one-per-type invariant. A submitted id can't be deleted (409, caught) — that submission correctly remains, and detaching still lets the user start a new Concept. */ reset() { if (id) { void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept id = undefined; ensuring = undefined; } if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true, }); }, }; }