Files
atomic-design-poc/apps/ssp/src/app/registratie/application/draft-sync.ts
T
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00

237 lines
8.8 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 {
SubmitApplicationRequest,
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} 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. 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(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<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)
// WP-35: 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();
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 adapter
.detail(linked)
.then((dto) => {
if (dto.status && dto.status.tag !== 'Concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(dto.draft ?? null);
})
.catch(() => {
id = undefined;
applyResume(null); // unknown/deleted id → start fresh
});
};
// Find the user's existing Concept of this type (at most one), if any.
const findConcept = async (): Promise<string | undefined> => {
try {
const parsed = parseApplications(await adapter.list());
return parsed.ok
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
: undefined;
} catch {
return undefined;
}
};
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();
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: SubmitApplicationRequest): Promise<Result<string, SubmitApplicationResponse>> {
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,
});
},
};
}