feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,160 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
import {
ApiClient,
OrgTemplateAdminViewDto,
OrgTemplateDto,
OrgTemplateVersionDto,
PublishOrgTemplateResponse,
SubOrgSummaryDto,
} from '@shared/infrastructure/api-client';
import {
OrgTemplate,
OrgTemplateAdminView,
OrgTemplateVersion,
PublishResult,
SubOrgSummary,
} from '@brief/domain/org-template';
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
/**
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
* rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
@Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter {
private client = inject(ApiClient);
async list(): Promise<Result<string, SubOrgSummary[]>> {
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r;
const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) {
const parsed = parseSubOrg(s);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
FAILED,
);
return r.ok ? parseAdminView(r.value) : r;
}
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
return r.ok ? parsePublish(r.value) : r;
}
async rollback(subOrgId: string, version: number): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) {
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
return ok(await res.blob());
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
return err('sub-org: bad shape');
return ok({
subOrgId: dto.subOrgId,
orgName: dto.orgName,
publishedVersion: dto.publishedVersion ?? 0,
});
}
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
return err('version: bad shape');
const template = parseOrgTemplate(dto.template);
if (!template.ok) return template;
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
}
export function parseOrgTemplateAdminView(
dto: OrgTemplateAdminViewDto,
): Result<string, OrgTemplateAdminView> {
const draft = parseOrgTemplate(dto.draft);
if (!draft.ok) return draft;
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
return err('admin-view: bad shape');
const history: OrgTemplateVersion[] = [];
for (const v of dto.history ?? []) {
const parsed = parseVersion(v);
if (!parsed.ok) return parsed;
history.push(parsed.value);
}
return ok({
draft: draft.value,
publishedVersion: dto.publishedVersion,
history,
unsentBriefs: dto.unsentBriefs,
});
}
const parseAdminView = parseOrgTemplateAdminView;
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
return err('publish: bad shape');
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
}
// --- toDto: domain → wire (for save) ---
function toDto(t: OrgTemplate): OrgTemplateDto {
return {
subOrgId: t.subOrgId,
orgName: t.orgName,
returnAddress: t.returnAddress,
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
footerContact: t.footerContact,
footerLegal: t.footerLegal,
signatureName: t.signatureName,
signatureRole: t.signatureRole,
signatureClosing: t.signatureClosing,
margins: { ...t.margins },
version: t.version,
};
}