From d1abd35b0d2b962b31fd25f51d0d8aeeded5a5ec Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 23 Jul 2026 11:07:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(registratie):=20WP-35=20=E2=80=94=20one=20?= =?UTF-8?q?Concept=20per=20case=20type=20(server-enforced)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../BigRegister.Api/Data/ApplicationStore.cs | 13 ++++-- backend/src/BigRegister.Api/Program.cs | 9 +++- backend/swagger.json | 10 +++++ .../BigRegister.Tests/ApplicationTests.cs | 31 +++++++++++++ docs/project/backlog/README.md | 2 +- .../backlog/WP-35-one-concept-per-type.md | 45 +++++++++++++++++++ .../application/draft-sync.spec.ts | 21 +++++++++ src/app/registratie/application/draft-sync.ts | 31 ++++++++----- src/app/shared/infrastructure/api-client.ts | 6 +++ 9 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 docs/project/backlog/WP-35-one-concept-per-type.md diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index 7002a29..fe2e595 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -41,17 +41,24 @@ public static class ApplicationStore private static readonly object _gate = new(); - public static Aanvraag Create(string type, string owner) + /// Create a Concept for — UNLESS one of this + /// already exists unsubmitted. WP-35: at most one Concept per + /// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort). + /// Race-free: the existence check and the insert share the single write gate. Returns + /// null when a duplicate would be created (the caller maps that to 409 Conflict). + public static Aanvraag? CreateConcept(string type, string owner) { var now = DateTimeOffset.UtcNow; - var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; lock (_gate) { using var db = Db.Create(); + if (db.Applications.Any(a => a.Owner == owner && a.Type == type && !a.Submitted)) + return null; + var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; db.Applications.Add(a); db.SaveChanges(); + return a; } - return a; } public static Aanvraag? Get(string id, string owner) diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 807095a..213c4fa 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -244,10 +244,15 @@ api.MapGet("/applications/{id}", (string id) => api.MapPost("/applications", (CreateApplicationRequest req) => { - var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner); + var a = ApplicationStore.CreateConcept(req.Type, DocumentStore.DemoOwner); + if (a is null) + return Results.Problem( + detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", + statusCode: StatusCodes.Status409Conflict); return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); }) -.Produces(StatusCodes.Status201Created); +.Produces(StatusCodes.Status201Created) +.ProducesProblem(StatusCodes.Status409Conflict); // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) => diff --git a/backend/swagger.json b/backend/swagger.json index 6e4b488..5047798 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -570,6 +570,16 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs index 76d3abc..30fc416 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -12,6 +12,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture private async Task Create(string type = "registratie") { + // WP-35: one Concept per type is now server-enforced, and these tests share one DB + // (IClassFixture). Clear any leftover Concept so each test starts from a clean slate. + foreach (var s in (await List())!.Where(x => x.Status.Tag == "Concept")) + await _client.DeleteAsync($"/api/v1/applications/{s.Id}"); var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); Assert.Equal(HttpStatusCode.Created, res.StatusCode); return (await res.Content.ReadFromJsonAsync())!; @@ -90,6 +94,33 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); } + // --- WP-35: one Concept per case type (server-enforced) --- + + [Fact] + public async Task Creating_a_second_concept_of_the_same_type_conflicts() + { + await Create("herregistratie"); + var dup = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); + Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode); + } + + [Fact] + public async Task A_concept_of_a_different_type_is_allowed() + { + await Create("registratie"); + var other = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); + Assert.Equal(HttpStatusCode.Created, other.StatusCode); + } + + [Fact] + public async Task A_new_concept_is_allowed_once_the_previous_one_is_submitted() + { + var a = await Create("registratie"); + (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + var next = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + Assert.Equal(HttpStatusCode.Created, next.StatusCode); + } + [Fact] public async Task Cancel_concept_removes_it() { diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index fe47042..a43ac72 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -79,7 +79,7 @@ for its existing violations, so every WP ends green. | [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done | | [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done | | [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done | -| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | todo | +| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | | [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | todo | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); diff --git a/docs/project/backlog/WP-35-one-concept-per-type.md b/docs/project/backlog/WP-35-one-concept-per-type.md new file mode 100644 index 0000000..7592346 --- /dev/null +++ b/docs/project/backlog/WP-35-one-concept-per-type.md @@ -0,0 +1,45 @@ +# WP-35 — One Concept per case type (server-enforced) + +Status: done +Phase: 7 — refinements + +## Why + +The FE already keeps at most one Concept (draft aanvraag) per type — but only as a client-side +convenience in `draft-sync.ts` (`resume()`/`findConcept()`/`resumeGate`). Per ADR-0001 the server +is the authority for business rules; the FE guard is best-effort and a cross-tab / stale-list race +can still POST a second Concept. This WP makes "at most one unsubmitted Concept per type" a +**server-enforced invariant**, and makes the FE recover gracefully when the server refuses. + +## Decisions (made while building — no spec existed; flagged for review) + +- **Enforce at create, not submit.** The invariant is about the _existence_ of Concepts, so the + guard lives in `POST /applications`. Enforcing at submit would only block submitting a duplicate, + not its existence — that doesn't satisfy the title. +- **Race-free in the store.** The check-and-insert happens atomically under the store's single + write gate (`ApplicationStore.CreateConcept`), not as a separate list-then-create in the handler. +- **409 Conflict** (ProblemDetails), matching the applications block's other guards + (cancel-after-submit, submit-twice) — not 422. The generated client now handles 409 explicitly. +- **FE recovery over error banner.** A create-409 means a Concept of this type already exists, so + `ensureId` adopts it (`findConcept`) instead of surfacing an error — the whole point of + one-per-type is that the second attempt lands you on the existing draft. Recovery fires only + when one actually exists; otherwise the original failure is surfaced. +- **Scope: only the persisted-lifecycle types** (`registratie | herregistratie | intake`). The + stateless submits (`telefoonwijziging`, legacy `/registrations` etc.) never create a Concept. + +## Files + +- `backend/.../Data/ApplicationStore.cs` — `Create` → `CreateConcept` (nullable; atomic guard). +- `backend/.../Program.cs` — `POST /applications` returns 409 when `CreateConcept` returns null. +- `backend/tests/.../ApplicationTests.cs` — helper clears leftover Concepts (tests share one DB); + +3 tests (dup conflicts, different type allowed, new allowed after submit). +- `src/app/registratie/application/draft-sync.ts` (+spec) — `ensureId` adopts the existing Concept + on a create-conflict. +- Regenerated `api-client.ts` / `swagger.json` (create now documents its 409). + +## Acceptance criteria + +- [x] A second unsubmitted Concept of the same type is refused server-side (409). +- [x] A different type, and a new Concept after the previous is submitted, are allowed. +- [x] FE recovers from the 409 by resuming the existing Concept (no error banner). +- [x] `npm run ci` green (333 FE tests, backend 125, api-client drift clean after commit). diff --git a/src/app/registratie/application/draft-sync.spec.ts b/src/app/registratie/application/draft-sync.spec.ts index 616526c..3b16cf5 100644 --- a/src/app/registratie/application/draft-sync.spec.ts +++ b/src/app/registratie/application/draft-sync.spec.ts @@ -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)', () => { diff --git a/src/app/registratie/application/draft-sync.ts b/src/app/registratie/application/draft-sync.ts index 6594a9e..ff533dd 100644 --- a/src/app/registratie/application/draft-sync.ts +++ b/src/app/registratie/application/draft-sync.ts @@ -63,17 +63,28 @@ export function createDraftSync(deps: DraftSyncDeps) { 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. - 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; }; diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts index 55e3a08..5a7ef29 100644 --- a/src/app/shared/infrastructure/api-client.ts +++ b/src/app/shared/infrastructure/api-client.ts @@ -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);