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>
6.5 KiB
WP-23 — Org-template backend + admin role
Status: done Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
Why
PRD "Brief opstellen v2" splits a rendered letter over two orthogonal template axes:
the case-type template (section structure + placeholders — exists, unchanged) and
a new organization template (appearance/identity per sub-organization: letterhead,
footer, signature, margins). This WP builds the second axis server-side plus the
admin role that will edit it (WP-26). Everything downstream (canvas WP-24, preview
WP-25, editor WP-26) reads what this WP serves.
Read first
docs/prd— the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)backend/src/BigRegister.Api/Data/BriefStore.cs(store idiom +BriefSeed)backend/src/BigRegister.Api/Domain/Authorization/Authz.cs(emit+enforce single source)backend/src/BigRegister.Api/Data/AppDbContext.cs(JSON-column precedent, WP-22)docs/backlog/WP-18-abac-capability-spine.md(how the capability spine works)
Decisions (pre-made, don't relitigate)
- No case model, no sub-org auth scoping.
GET /briefstays; the brief just gains aSubOrgId. Two seeded sub-orgs (cibg-registers,cibg-vakbekwaamheid) exist purely so the admin editor can demo isolation. - One row per sub-org, versions as a JSON history column
(
OrgTemplateEntity { SubOrgId PK, Draft json, PublishedVersion, History json }) — the WP-22 JSON-column precedent; no extra tables. Publish = append draft snapshot to history + version++. Rollback = copyHistory[v]intoDraft(history stays append-only; admin republishes). - Sent letters are immutable:
SendpinsSentOrgTemplateVersion; a sent brief'sBriefViewDto.orgTemplateresolves from history, never from the current published version. Unsent briefs always follow the current published version — that is the point of the admin editor. - Admin = third
X-Rolevalue (PrincipalRole.Admin), capabilityorgtemplate:editviaGET /me. Approve/reject gain an explicitRole == Approvercondition so the new role cannot slip through the SoD-only check. The existingX-Admindocument-deletion seam stays untouched. - Trimmed model (PRD §3 minus): no signature image asset, no structured address objects, no per-template fonts. Return address / footer contact are multiline strings. Margins are 4 bounded ints (mm, 10–50) — server-validated.
- Logo = existing upload machinery: seed an
org-logocategory (png/jpeg, 1 MB) under aorg-templatewizardId; the template stores onlylogoDocumentId. - Seed values come from the sample artifact
voorbeeldbrief-inschrijving.pdf(A. de Vries / Hoofd Registratie / Postbus 00000 / info@voorbeeld.example — all fictitious).
Files
backend/src/BigRegister.Api/Contracts/Dtos.cs—MarginsDto,OrgTemplateDto,OrgTemplateVersionDto,OrgTemplateAdminViewDto,SaveOrgTemplateRequest,PublishOrgTemplateResponse,SubOrgSummaryDto;BriefViewDto+OrgTemplate.backend/src/BigRegister.Api/Data/OrgTemplateStore.cs(new) — entity + store + seed.backend/src/BigRegister.Api/Data/AppDbContext.cs— OrgTemplates DbSet + JSON converters.backend/src/BigRegister.Api/Data/Migrations/*— new migration.backend/src/BigRegister.Api/Data/BriefStore.cs—SubOrgId,SentOrgTemplateVersion, pin atSend.backend/src/BigRegister.Api/Domain/Authorization/Authz.cs—Adminrole, capability, gate.backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs—org-logocategory.backend/src/BigRegister.Api/Program.cs— 5 admin endpoints,ToVieworgTemplate resolution.backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs(new).- Regenerated:
backend/swagger.json,src/app/shared/infrastructure/api-client.ts. - FE seam only:
src/app/shared/domain/role.ts,shared/domain/capability.ts,shared/infrastructure/role.ts,shared/infrastructure/role.interceptor.ts.
Steps
- DTOs (above).
OrgTemplateEntity+OrgTemplateStore(list/get/saveDraft/publish/rollback/ published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs, draft == published v1) + AppDbContext mapping + migration.PrincipalRole.Admin;ResolvePrincipalreadsadmin;RoleCapabilities(Admin)→orgtemplate:edit; approve/reject checks requireApproverexplicitly.- Endpoints:
GET /admin/org-templates,GET|PUT /admin/org-template/{subOrgId},POST …/publish(returns impact count = unsent briefs of that sub-org),POST …/rollback/{version}. All 403 for non-admin viaAuthz. BriefEntity.SubOrgId(seedcibg-registers) +SentOrgTemplateVersion;Sendpins;ToViewresolves published-vs-pinned intoBriefViewDto.orgTemplate.- Seed
org-logoupload category. npm run gen:api.- FE: widen
Role/Capabilityunions,currentRole(), interceptor URL filter (nothing consumes them yet — WP-24/26 do).
Acceptance criteria
- Publish increments
publishedVersionand appends to history; rollback copies an old version into the draft without rewriting history. - Every admin endpoint returns 403 for drafter/approver, 200 for
X-Role: admin. - A sent brief keeps its pinned org-template version after a republish; an unsent brief follows the new published version (both asserted in one test).
- Publish impact count = number of unsent briefs of that sub-org.
PUTwith out-of-bounds margins → 400.GET /briefcarriesorgTemplate; existing brief tests stay green.GET /mewithX-Role: admin→["orgtemplate:edit"].- Full GREEN (FE untouched functionally, but lint/test/build/storybook all pass).
Verification
cd backend && dotnet test; GREEN one-liner; curl smoke: admin list/save/publish
(200) vs drafter (403); sent-brief pin walk-through per acceptance.
Out of scope
The canvas (WP-24), HTML preview endpoints + archive-at-send (WP-25), the admin UI (WP-26). Template approval chains (draft→publish is enough for the POC; flagged as an open question in the PRD). Sub-org-scoped brief authorization.
Risks
BriefViewDto gains a field — additive, but the FE parseBriefView boundary and
generated client must be regenerated in the same WP to keep the drift check green.
Adding PrincipalRole.Admin touches the approve/reject SoD path: the explicit
Role == Approver condition must preserve today's Forbidden-before-Conflict order
(existing tests prove it).