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

@@ -2,4 +2,4 @@
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
* Server-resolved and opaque to the FE — never derived from a role client-side.
*/
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send';
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit';

View File

@@ -1,7 +1,8 @@
/**
* The two-person letter workflow's role: who is acting, a drafter or an approver.
* The letter workflow's acting role: drafter or approver for the two-person
* compose/review flow, admin for org-template management (WP-23, Brief v2).
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
* letter-composer) depend on this type, not on how the role is obtained.
*/
export type Role = 'drafter' | 'approver';
export type Role = 'drafter' | 'approver' | 'admin';

View File

@@ -1279,6 +1279,257 @@ export class ApiClient {
}
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
orgTemplates(): Promise<SubOrgSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/admin/org-templates";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplates(_response);
});
}
protected processOrgTemplates(response: Response): Promise<SubOrgSummaryDto[]> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubOrgSummaryDto[];
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<SubOrgSummaryDto[]>(null as any);
}
/**
* @return OK
*/
orgTemplateGET(subOrgId: string): Promise<OrgTemplateAdminViewDto> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplateGET(_response);
});
}
protected processOrgTemplateGET(response: Response): Promise<OrgTemplateAdminViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
}
/**
* @return OK
*/
orgTemplatePUT(subOrgId: string, body: SaveOrgTemplateRequest): Promise<OrgTemplateAdminViewDto> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "PUT",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplatePUT(_response);
});
}
protected processOrgTemplatePUT(response: Response): Promise<OrgTemplateAdminViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
return result200;
});
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
}
/**
* @return OK
*/
orgTemplatePublish(subOrgId: string): Promise<PublishOrgTemplateResponse> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}/publish";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplatePublish(_response);
});
}
protected processOrgTemplatePublish(response: Response): Promise<PublishOrgTemplateResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PublishOrgTemplateResponse;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<PublishOrgTemplateResponse>(null as any);
}
/**
* @return OK
*/
orgTemplateRollback(subOrgId: string, version: number): Promise<OrgTemplateAdminViewDto> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}/rollback/{version}";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
if (version === undefined || version === null)
throw new globalThis.Error("The parameter 'version' must be defined.");
url_ = url_.replace("{version}", encodeURIComponent("" + version));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplateRollback(_response);
});
}
protected processOrgTemplateRollback(response: Response): Promise<OrgTemplateAdminViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
}
}
export interface AantekeningDto {
@@ -1356,6 +1607,7 @@ export interface BriefViewDto {
brief?: BriefDto;
availablePassages?: LibraryPassageDto[] | undefined;
decisions?: BriefDecisionsDto;
orgTemplate?: OrgTemplateDto;
}
export interface BrpAddressDto {
@@ -1467,10 +1719,44 @@ export interface ManualDiplomaPolicyDto {
policyQuestions?: PolicyQuestionDto[] | undefined;
}
export interface MarginsDto {
topMm?: number;
rightMm?: number;
bottomMm?: number;
leftMm?: number;
}
export interface MeDto {
capabilities?: string[] | undefined;
}
export interface OrgTemplateAdminViewDto {
draft?: OrgTemplateDto;
publishedVersion?: number;
history?: OrgTemplateVersionDto[] | undefined;
unsentBriefs?: number;
}
export interface OrgTemplateDto {
subOrgId?: string | undefined;
orgName?: string | undefined;
returnAddress?: string | undefined;
logoDocumentId?: string | undefined;
footerContact?: string | undefined;
footerLegal?: string | undefined;
signatureName?: string | undefined;
signatureRole?: string | undefined;
signatureClosing?: string | undefined;
margins?: MarginsDto;
version?: number;
}
export interface OrgTemplateVersionDto {
version?: number;
publishedAt?: string | undefined;
template?: OrgTemplateDto;
}
export interface ParagraphDto {
nodes?: RichTextNodeDto[] | undefined;
list?: string | undefined;
@@ -1506,6 +1792,11 @@ export interface ProblemDetails {
[key: string]: any;
}
export interface PublishOrgTemplateResponse {
version?: number;
affectedUnsentBriefs?: number;
}
export interface ReferentieResponse {
referentie?: string | undefined;
}
@@ -1551,6 +1842,16 @@ export interface SaveBriefRequest {
sections?: LetterSectionDto[] | undefined;
}
export interface SaveOrgTemplateRequest {
draft?: OrgTemplateDto;
}
export interface SubOrgSummaryDto {
subOrgId?: string | undefined;
orgName?: string | undefined;
publishedVersion?: number;
}
export interface SubmitApplicationRequest {
diplomaHerkomst?: string | undefined;
uren?: number | undefined;

View File

@@ -11,6 +11,13 @@ describe('parseMe (trust boundary)', () => {
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
});
it('recognizes the admin org-template capability (WP-23)', () => {
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
ok: true,
value: ['orgtemplate:edit'],
});
});
it('drops unrecognized capability strings instead of rejecting the response', () => {
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
expect(r).toEqual({ ok: true, value: ['brief:approve'] });

View File

@@ -3,7 +3,12 @@ import { Result, ok, err } from '@shared/kernel/fp';
import { Capability } from '@shared/domain/capability';
import { ApiClient } from '@shared/infrastructure/api-client';
const KNOWN: readonly Capability[] = ['brief:approve', 'brief:reject', 'brief:send'];
const KNOWN: readonly Capability[] = [
'brief:approve',
'brief:reject',
'brief:send',
'orgtemplate:edit',
];
/**
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's

View File

@@ -2,11 +2,15 @@ import { HttpInterceptorFn } from '@angular/common/http';
import { currentRole } from './role';
/**
* Dev-only: stamps brief requests with the current `?role=` as an `X-Role` header so
* the backend can enforce the drafter/approver rules. Only brief endpoints carry it;
* everything else is untouched.
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
* header so the backend can enforce the drafter/approver/admin rules. Only the
* brief, org-template and /me endpoints carry it (WP-23 widened the set — /me must
* see the role or `AccessStore` could never learn a capability); everything else
* is untouched.
*/
const ROLE_AWARE = ['/api/v1/brief', '/api/v1/admin/org-template', '/api/v1/me'];
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('/api/v1/brief')) return next(req);
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
};

View File

@@ -11,7 +11,6 @@ import { Role } from '@shared/domain/role';
* longer derives permission from this value itself.
*/
export function currentRole(): Role {
return new URLSearchParams(window.location.search).get('role') === 'approver'
? 'approver'
: 'drafter';
const role = new URLSearchParams(window.location.search).get('role');
return role === 'approver' || role === 'admin' ? role : 'drafter';
}