Files
atomic-design-poc/apps/ssp/src/app/registratie/application/draft-sync.ts
T
ehoandClaude Sonnet 5 dd11eafe50 refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)
204 WP-NN/RB-NN comments named a closed ticket instead of the code they
sit next to. git blame already records history and stays correct when
code moves; the comment does not. This sweep removes the reference and
keeps the sentence, across 95 files in apps/ and libs/ plus the
behaviour-spec generator's header text.

Eleven references stay: five story files justify an a11y disable per
the README's rule, and one line in a11y.mdx documents that convention.
Two sentences needed a rewrite, not a deletion, so the reference's
meaning survives its removal. behaviour-spec.mdx is regenerated, not
hand-edited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:23:07 +02:00

217 lines
8.3 KiB
TypeScript

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=<id>` 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<string> | undefined; // in-flight create, so we never create twice
let timer: ReturnType<typeof setTimeout> | 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<unknown> = Promise.resolve();
const ensureId = async (): Promise<string> => {
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<void> => {
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<void>((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<Result<string, AanvraagIndienenResponse>> {
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,
});
},
};
}