From 0f14239f6808bb3431fdf7cd94e93ea88527b49c Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 1 Jul 2026 16:34:01 +0200 Subject: [PATCH] Fix Mijn aanvragen: instant cancel + one Concept per type (resume) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../application/applications.store.ts | 61 +++++++----- src/app/registratie/application/draft-sync.ts | 98 ++++++++++++++----- .../infrastructure/applications.adapter.ts | 6 +- .../registratie-wizard.component.ts | 2 +- 4 files changed, 112 insertions(+), 55 deletions(-) diff --git a/src/app/registratie/application/applications.store.ts b/src/app/registratie/application/applications.store.ts index b2f689d..7f6516a 100644 --- a/src/app/registratie/application/applications.store.ts +++ b/src/app/registratie/application/applications.store.ts @@ -1,5 +1,5 @@ -import { Injectable, computed, inject, signal } from '@angular/core'; -import { RemoteData, fromResource } from '@shared/application/remote-data'; +import { Injectable, inject, signal } from '@angular/core'; +import { RemoteData } from '@shared/application/remote-data'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter'; @@ -7,45 +7,52 @@ type Err = Error | undefined; /** * The dashboard's view of the user's applications (aanvragen) — the backend is the - * system of record (PRD 0001). One root singleton owns the list resource and exposes - * it as a RemoteData signal, validated at the trust boundary (DTO → domain). Cancel - * is optimistic (hide immediately, reload to confirm, un-hide on failure); `reload` - * re-fetches so a page revisit reflects auto-approval (Concept → In behandeling → - * Goedgekeurd is computed server-side on read). + * system of record (PRD 0001). One root singleton OWNS the list as a writable + * RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes + * the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on + * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches + * so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is + * computed server-side on read). */ @Injectable({ providedIn: 'root' }) export class ApplicationsStore { private adapter = inject(ApplicationsAdapter); - private res = this.adapter.applicationsResource(); - /** Ids optimistically hidden while their cancel is in flight (or done). */ - private cancelling = signal>(new Set()); - readonly applications = computed>(() => { - const rd = fromResource(this.res); - if (rd.tag !== 'Success') return rd; - const parsed = parseApplications(rd.value ?? []); - if (!parsed.ok) return { tag: 'Failure', error: new Error(parsed.error) }; - const hidden = this.cancelling(); - return { tag: 'Success', value: parsed.value.filter((a) => !hidden.has(a.id)) }; - }); + private state = signal>({ tag: 'Loading' }); + readonly applications = this.state.asReadonly(); + + constructor() { + void this.load(); + } + + /** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the + last-good value on a resync (only shows Loading on the first load). */ + async load() { + if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); + try { + const parsed = parseApplications(await this.adapter.list()); + this.state.set(parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }); + } catch (e) { + this.state.set({ tag: 'Failure', error: e as Error }); + } + } /** Re-fetch (e.g. on dashboard revisit) so auto-approval transitions show up. */ reload() { - this.res.reload(); + void this.load(); } - /** Cancel a Concept: hide it optimistically, then confirm (reload) or roll back. */ + /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. + No resync — the delete succeeded, so the optimistic removal is authoritative. */ async cancel(id: string) { - this.cancelling.update((s) => new Set(s).add(id)); + const before = this.state(); + if (before.tag === 'Success') { + this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); + } try { await this.adapter.cancel(id); - this.res.reload(); // the reloaded list no longer contains it; the hide is now a no-op } catch { - this.cancelling.update((s) => { - const next = new Set(s); - next.delete(id); - return next; // roll back: the block reappears - }); + this.state.set(before); // roll back: the block reappears } } } diff --git a/src/app/registratie/application/draft-sync.ts b/src/app/registratie/application/draft-sync.ts index e86e896..3af49ef 100644 --- a/src/app/registratie/application/draft-sync.ts +++ b/src/app/registratie/application/draft-sync.ts @@ -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=`, 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=` 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 | 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 = (): Promise => { - if (id) return Promise.resolve(id); + const ensureId = async (): Promise => { + 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 => { + 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 => { + 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((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 }); }, }; diff --git a/src/app/registratie/infrastructure/applications.adapter.ts b/src/app/registratie/infrastructure/applications.adapter.ts index 6fc0c9c..4c65be0 100644 --- a/src/app/registratie/infrastructure/applications.adapter.ts +++ b/src/app/registratie/infrastructure/applications.adapter.ts @@ -1,4 +1,4 @@ -import { Injectable, inject, resource } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { ApiClient, @@ -23,8 +23,8 @@ export class ApplicationsAdapter { private client = inject(ApiClient); /** The dashboard's application list (raw DTOs; the store parses at the boundary). */ - applicationsResource() { - return resource({ loader: () => this.client.applicationsAll() }); + list(): Promise { + return this.client.applicationsAll(); } detail(id: string): Promise { 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 b336958..0643390 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -375,7 +375,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.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress this.dispatch({ tag: 'Seed', state: initial }); this.adresRes.reload(); }