feat(fp): WP-23 — org-template backend + admin role

Second template axis (org identity: letterhead, footer, signature,
margins) server-side: OrgTemplateStore with JSON version history,
publish/rollback, sent-brief version pinning, admin role + capability,
5 admin endpoints, org-logo upload category. FE seam widened only
(Role/Capability unions, interceptor); WP-24/26 consume it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 11:17:05 +02:00
parent 44eb2d2186
commit 5a610c10f0
31 changed files with 5017 additions and 1834 deletions

View File

@@ -6,6 +6,7 @@ using BigRegister.Domain.Authorization;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using Microsoft.EntityFrameworkCore;
@@ -354,16 +355,77 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
.WithName("briefReset")
.Produces<BriefViewDto>();
// --- Organization templates (WP-23): the second template axis — appearance and
// identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam
// as drafter/approver); the same Authz check gates every endpoint and feeds the
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
Results.Ok(OrgTemplateStore.List())))
.WithName("orgTemplates")
.Produces<List<SubOrgSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
.WithName("orgTemplateGET")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var reject = OrgTemplateRules.RejectDraft(req.Draft);
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
}))
.WithName("orgTemplatePUT")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var r = OrgTemplateStore.Publish(subOrgId, Now());
if (r is not null)
app.Logger.LogInformation("orgtemplate publish subOrg={SubOrg} version={Version} affected={Affected}",
subOrgId, r.Version, r.AffectedUnsentBriefs);
return r is not null ? Results.Ok(r) : Results.NotFound();
}))
.WithName("orgTemplatePublish")
.Produces<PublishOrgTemplateResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
.WithName("orgTemplateRollback")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
// One gate for every org-template endpoint — the enforce twin of the
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
static IResult OrgAdmin(HttpContext ctx, Func<IResult> action) =>
Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx))
? action()
: Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
statusCode: StatusCodes.Status403Forbidden);
static string Now() => DateTimeOffset.UtcNow.ToString("o");
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
e.ToDto(),
BriefSeed.PassagesFor(e.Beroep),
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId));
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
// Sent letters render with the version pinned at send; everything else follows
// the sub-org's current published template (WP-23 immutability invariant).
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null));
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.