refactor(backend): reorder Program.cs sections into reads-then-writes (RB-19)
CQ-006 found that Program.cs states a reads-then-writes principle at the
top of the file, then abandons it for five feature sections that mix GET
and mutating endpoints in mapping order. This is a pure reorder: within
Document upload, Applications, Admin cases, Brief, and Organization
templates, every GET now precedes every POST/PUT/DELETE, each split by a
`--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already
established for Beoordeling/Besluit.
DELETE /admin/cases/{id} and GET /admin/audit move up beside GET
/admin/cases, closing the 129-line gap CQ-006 measured. GET
/admin/org-template/{subOrgId}/preview moves from the Brief section to
the Organization-templates section it actually belongs to.
No route, signature, DTO, or handler body changed. Every block was cut
by exact line-range slicing, never retyped. The sorted list of mapped
HTTP-method-plus-path strings is byte-identical before and after; every
.Gate(...) count is unchanged; the three routes that moved with a gate
were checked by eye against the wrapper their handler actually calls,
per RB-12's stated limitation that the route-table test only proves a
marker is present, not that it still matches the handler.
npm run gen:api regenerated backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts; both diffs are ordering
only (sorted-file diff is empty), committed alongside per the ticket's
own guidance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -242,36 +242,12 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
|
||||
// --- Document upload ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
|
||||
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
|
||||
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList()));
|
||||
|
||||
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
|
||||
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
|
||||
// and size authoritatively; stores metadata only (no file bytes / PII held).
|
||||
api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) =>
|
||||
{
|
||||
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
||||
var form = await request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file");
|
||||
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
||||
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
||||
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
||||
|
||||
var category = DocumentRules.Find(wizardId, categoryId);
|
||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
// WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
|
||||
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
|
||||
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
|
||||
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener());
|
||||
return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
|
||||
// for pdf/image (browser renders it), attachment otherwise (download).
|
||||
// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a
|
||||
@@ -305,6 +281,34 @@ api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) =>
|
||||
return new UploadStatusDto(results);
|
||||
});
|
||||
|
||||
// --- writes ---
|
||||
|
||||
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
|
||||
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
|
||||
// and size authoritatively; stores metadata only (no file bytes / PII held).
|
||||
api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) =>
|
||||
{
|
||||
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
||||
var form = await request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file");
|
||||
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
||||
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
||||
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
||||
|
||||
var category = DocumentRules.Find(wizardId, categoryId);
|
||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
// WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
|
||||
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
|
||||
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
|
||||
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener());
|
||||
return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
||||
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch
|
||||
@@ -332,6 +336,8 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
|
||||
|
||||
// --- Applications (aanvragen): the system of record the dashboard reads. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
// WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling
|
||||
// ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from
|
||||
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
|
||||
@@ -346,6 +352,8 @@ api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
|
||||
.Produces<ApplicationDetailDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- writes ---
|
||||
|
||||
api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
|
||||
{
|
||||
// Feature flag (WP-47): self-service registration can be closed by an admin.
|
||||
@@ -480,12 +488,40 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
|
||||
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces<List<ApplicationSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
|
||||
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
|
||||
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
Results.Ok(AuthzAuditStore.List()
|
||||
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
|
||||
.ToList())))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces<List<AuthzAuditDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// --- writes ---
|
||||
|
||||
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
|
||||
// DELETE /applications/{id}. A missing id is a 404.
|
||||
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
{
|
||||
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
|
||||
app.Logger.LogInformation("admin case delete id={Id}", id);
|
||||
return Results.NoContent();
|
||||
}))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. ---
|
||||
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`,
|
||||
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags —
|
||||
@@ -618,29 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
|
||||
// /uploads and /brief/reveal-bignummer.
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
|
||||
// DELETE /applications/{id}. A missing id is a 404.
|
||||
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
{
|
||||
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
|
||||
app.Logger.LogInformation("admin case delete id={Id}", id);
|
||||
return Results.NoContent();
|
||||
}))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
|
||||
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
|
||||
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
Results.Ok(AuthzAuditStore.List()
|
||||
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
|
||||
.ToList())))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces<List<AuthzAuditDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
||||
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
||||
// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like
|
||||
@@ -673,6 +686,8 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
|
||||
// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real
|
||||
// identities in this POC. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
api.MapGet("/brief", (HttpContext ctx) =>
|
||||
{
|
||||
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
|
||||
@@ -685,6 +700,27 @@ api.MapGet("/brief", (HttpContext ctx) =>
|
||||
.Produces<BriefViewDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
// RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
|
||||
// must not create a brief as a side effect either, so it 404s under the same
|
||||
// precondition as GET /brief — in the running app the FE only reaches this endpoint
|
||||
// from the brief page, which has already loaded (and, if needed, reset) a brief.
|
||||
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
|
||||
if (e is null) return Results.NotFound();
|
||||
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||
return Results.Content(archived, "text/html");
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// --- writes ---
|
||||
|
||||
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
@@ -765,37 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
|
||||
// OpenAPI doc, same seam as /brief/preview and uploads.
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
// RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
|
||||
// must not create a brief as a side effect either, so it 404s under the same
|
||||
// precondition as GET /brief — in the running app the FE only reaches this endpoint
|
||||
// from the brief page, which has already loaded (and, if needed, reset) a brief.
|
||||
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
|
||||
if (e is null) return Results.NotFound();
|
||||
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||
return Results.Content(archived, "text/html");
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||
// brief, so the appearance can be checked before publishing touches real letters.
|
||||
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||
if (view is null) return Results.NotFound();
|
||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||
}))
|
||||
.Gate("OrgAdmin")
|
||||
.ExcludeFromDescription();
|
||||
|
||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
@@ -810,6 +815,8 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
// as drafter/approver); the same Authz check gates every endpoint and feeds the
|
||||
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
Results.Ok(OrgTemplateStore.List())))
|
||||
.Gate("OrgAdmin")
|
||||
@@ -825,6 +832,20 @@ api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||
// brief, so the appearance can be checked before publishing touches real letters.
|
||||
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||
if (view is null) return Results.NotFound();
|
||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||
}))
|
||||
.Gate("OrgAdmin")
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// --- writes ---
|
||||
|
||||
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var reject = OrgTemplateRules.RejectDraft(req.Draft);
|
||||
|
||||
+67
-67
@@ -686,6 +686,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/audit": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuthzAuditDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cases/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/werkvoorraad": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -832,73 +899,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cases/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/audit": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuthzAuditDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user