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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user