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>
115 lines
6.5 KiB
Markdown
115 lines
6.5 KiB
Markdown
# 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).
|