Fix Mijn aanvragen: instant cancel + one Concept per type (resume)

Two dashboard bugs from the just-built feature.

1. Cancel didn't reflect until a browser refresh. ApplicationsStore now OWNS the
   list in a writable RemoteData signal instead of projecting a resource() through
   fromResource; cancel removes the row synchronously (guaranteed disappear, no
   dependence on CD timing / HTTP cache / the reloading gap), then confirms the
   DELETE (rollback on failure, no resync). Adapter gains list(); applicationsResource()
   removed. Shared fromResource/remote-data.ts deliberately untouched.

2. Duplicate / inconsistent Concepts per type. createDraftSync.resume() now: a
   ?aanvraag link wins; else it resumes THIS type's existing Concept (loads its
   draft); else fresh. ensureId is gated behind resume so a fast typist can't create
   a duplicate before the lookup lands. restart()/reset() deletes the current Concept
   (submitted → 409, kept) so there's at most one active Concept per type. A non-Concept
   id can't reopen as an editable draft. Backend unchanged.

Gates green: lint, vitest 128, build, check:tokens, backend dotnet 56.
Wiring is not unit-covered — needs live verification (see plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-01 16:34:01 +02:00
co-authored by Claude Opus 4.8
parent 168cf9786c
commit 0f14239f68
4 changed files with 112 additions and 55 deletions
+74 -24
View File
@@ -4,7 +4,7 @@ import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot {
@@ -31,9 +31,10 @@ const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels lag
* Concept (PRD 0001, phase D). Instantiated in a field initializer (like
* `createStore`/`createUploadController`). Responsibilities:
*
* - resume: if the URL carries `?aanvraag=<id>`, 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);
* - 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.
@@ -47,9 +48,13 @@ export function createDraftSync(deps: DraftSyncDeps) {
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 = (): Promise<string> => {
if (id) return Promise.resolve(id);
const ensureId = async (): Promise<string> => {
await resumeGate;
if (id) return id;
ensuring ??= adapter.create(deps.type).then((newId) => {
id = newId;
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
@@ -78,23 +83,63 @@ export function createDraftSync(deps: DraftSyncDeps) {
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
// 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;
deps.onResume(null);
return;
}
deps.onResume(dto.draft ?? null);
})
.catch(() => {
id = undefined;
deps.onResume(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 {
/** Resolve the initial state: resume a linked Concept, or start fresh. */
resume() {
if (!active()) {
/** 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()) {
deps.onResume(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;
}
deps.onResume(null);
return;
} finally {
release();
}
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
},
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
@@ -104,11 +149,16 @@ export function createDraftSync(deps: DraftSyncDeps) {
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
},
/** Detach from the current Concept (a new one is created on next progress) and
drop the `?aanvraag` link. Used when the wizard restarts. */
/** 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() {
id = undefined;
ensuring = undefined;
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 });
},
};