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:
@@ -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 <paramref name="owner"/> — UNLESS one of this
|
||||
/// <paramref name="type"/> 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)
|
||||
|
||||
@@ -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<ApplicationDetailDto>(StatusCodes.Status201Created);
|
||||
.Produces<ApplicationDetailDto>(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) =>
|
||||
|
||||
@@ -570,6 +570,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
|
||||
private async Task<ApplicationDetailDto> 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<ApplicationDetailDto>())!;
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
@@ -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