# 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 /brief` stays; the brief just gains a `SubOrgId`. 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 = copy `History[v]` into `Draft` (history stays append-only; admin republishes). - **Sent letters are immutable**: `Send` pins `SentOrgTemplateVersion`; a sent brief's `BriefViewDto.orgTemplate` resolves 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-Role` value** (`PrincipalRole.Admin`), capability `orgtemplate:edit` via `GET /me`. Approve/reject gain an explicit `Role == Approver` condition so the new role cannot slip through the SoD-only check. The existing `X-Admin` document-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-logo` category (png/jpeg, 1 MB) under a `org-template` wizardId; the template stores only `logoDocumentId`. - 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 at `Send`. - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` — `Admin` role, capability, gate. - `backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs` — `org-logo` category. - `backend/src/BigRegister.Api/Program.cs` — 5 admin endpoints, `ToView` orgTemplate 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 1. DTOs (above). 2. `OrgTemplateEntity` + `OrgTemplateStore` (list/get/saveDraft/publish/rollback/ published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs, draft == published v1) + AppDbContext mapping + migration. 3. `PrincipalRole.Admin`; `ResolvePrincipal` reads `admin`; `RoleCapabilities(Admin)` → `orgtemplate:edit`; approve/reject checks require `Approver` explicitly. 4. 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 via `Authz`. 5. `BriefEntity.SubOrgId` (seed `cibg-registers`) + `SentOrgTemplateVersion`; `Send` pins; `ToView` resolves published-vs-pinned into `BriefViewDto.orgTemplate`. 6. Seed `org-logo` upload category. 7. `npm run gen:api`. 8. FE: widen `Role`/`Capability` unions, `currentRole()`, interceptor URL filter (nothing consumes them yet — WP-24/26 do). ## Acceptance criteria - [x] Publish increments `publishedVersion` and appends to history; rollback copies an old version into the draft without rewriting history. - [x] Every admin endpoint returns 403 for drafter/approver, 200 for `X-Role: admin`. - [x] 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). - [x] Publish impact count = number of unsent briefs of that sub-org. - [x] `PUT` with out-of-bounds margins → 400. - [x] `GET /brief` carries `orgTemplate`; existing brief tests stay green. - [x] `GET /me` with `X-Role: admin` → `["orgtemplate:edit"]`. - [x] 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).