feat(registratie): WP-35 — one Concept per case type (server-enforced)
Make "at most one unsubmitted Concept per type" a server invariant instead of a client-only convenience. ApplicationStore.Create → CreateConcept guards atomically under the write gate and POST /applications returns 409 when a duplicate would be created. The FE draft-sync recovers from the 409 by adopting the existing Concept (ensureId → findConcept) rather than erroring — one-per-type means the second attempt lands on the existing draft. Typed client regenerated (documents the 409). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,27 @@ describe('createDraftSync', () => {
|
||||
const r = await draftSync.submit({});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => {
|
||||
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
|
||||
// and ensureId adopts the existing Concept from the list instead of erroring.
|
||||
const create = vi.fn().mockRejectedValue({ status: 409 });
|
||||
const list = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'existing-1',
|
||||
type: 'registratie',
|
||||
status: { tag: 'Concept', stepIndex: 1, stepCount: 3 },
|
||||
createdAt: '2026-07-23T10:00:00Z',
|
||||
updatedAt: '2026-07-23T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
const submit = vi.fn().mockResolvedValue({ id: 'existing-1', autoApprovable: true });
|
||||
const { draftSync } = setup({ create, list, submit });
|
||||
|
||||
const r = await draftSync.submit({});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(submit).toHaveBeenCalledWith('existing-1', {}); // adopted, not a new id
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||
|
||||
@@ -63,17 +63,28 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
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.
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: newId },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
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 newId;
|
||||
});
|
||||
return ensuring;
|
||||
};
|
||||
|
||||
|
||||
@@ -832,6 +832,12 @@ export class ApiClient {
|
||||
result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto;
|
||||
return result201;
|
||||
});
|
||||
} else if (status === 409) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result409: any = null;
|
||||
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
|
||||
Reference in New Issue
Block a user