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,45 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient } from '@shared/infrastructure/api-client';
import type { AuthzAuditDto } from '@shared/infrastructure/api-client';
import { AuditEntry } from '@beheer/domain/audit-entry';
/**
* Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`,
* WP-41). The single place the ApiClient lives for audit; the store parses at the boundary.
*/
@Injectable({ providedIn: 'root' })
export class AuditAdapter {
private client = inject(ApiClient);
list(): Promise<AuthzAuditDto[]> {
return this.client.audit();
}
}
/** Trust-boundary parse of the audit rows. */
export function parseAuditEntries(json: unknown): Result<string, AuditEntry[]> {
if (!Array.isArray(json)) return err('audit: not an array');
const out: AuditEntry[] = [];
for (const item of json) {
if (typeof item !== 'object' || item === null) return err('audit: row not an object');
const d = item as AuthzAuditDto;
if (
typeof d.at !== 'string' ||
typeof d.action !== 'string' ||
typeof d.resource !== 'string' ||
typeof d.role !== 'string' ||
typeof d.correlationId !== 'string'
)
return err('audit: missing fields');
out.push({
at: d.at,
action: d.action,
resource: d.resource,
decision: d.decision === 'allow' ? 'allow' : 'deny',
role: d.role,
correlationId: d.correlationId,
});
}
return ok(out);
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { parseStamdataTable } from './stamdata.adapter';
const wire = {
id: 'professions',
label: 'Opleiding → beroep',
temporal: true,
columns: [
{ name: 'program', type: 'text', isKey: true },
{ name: 'beroep', type: 'text', isKey: false },
{ name: 'geldigVan', type: 'date', isKey: false },
{ name: 'geldigTot', type: 'date', isKey: false },
],
rows: [{ program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: null }],
};
describe('parseStamdataTable', () => {
it('maps schema + rows and turns a null cell into empty text', () => {
const r = parseStamdataTable(wire);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.table.temporal).toBe(true);
expect(r.value.table.columns[0]).toMatchObject({ name: 'program', isKey: true, type: 'text' });
expect(r.value.rows[0]).toEqual({
program: 'geneeskunde',
beroep: 'Arts',
geldigVan: '2000-01-01',
geldigTot: '', // null → '' so the editor renders an empty (open-ended) cell
});
});
it('falls back to text for an unknown column type', () => {
const r = parseStamdataTable({
...wire,
columns: [{ name: 'x', type: 'weird', isKey: true }],
rows: [],
});
if (!r.ok) return;
expect(r.value.table.columns[0].type).toBe('text');
});
it('rejects a response with no columns', () => {
expect(parseStamdataTable({ id: 't', columns: [], rows: [] }).ok).toBe(false);
});
});
@@ -0,0 +1,99 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';
import { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';
/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */
export interface LoadedTable {
table: StamTable;
rows: StamRow[];
}
const FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;
const COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];
/**
* The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the
* generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain
* model. There is no write method — the edit is downloaded and lands as a PR.
*/
@Injectable({ providedIn: 'root' })
export class StamdataAdapter {
private client = inject(ApiClient);
/** The tables in the catalog (schema only, no rows) — for the table switcher. */
async list(): Promise<Result<string, StamTable[]>> {
const r = await runSubmit(() => this.client.stamdataTables(), FAILED);
if (!r.ok) return r;
const out: StamTable[] = [];
for (const t of r.value ?? []) {
const parsed = parseTable(t);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
/** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows
valid on that date; the editor uses it for a server-side cross-check, previewing
locally for instant feedback (see `activeOn`). */
async load(tableId: string, peildatum?: string): Promise<Result<string, LoadedTable>> {
const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);
return r.ok ? parseStamdataTable(r.value) : r;
}
}
// --- parse: wire → domain, validating at the boundary ---
/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its
spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */
export function parseStamdataTable(dto: {
id?: string;
label?: string;
columns?: StamdataColumnDto[];
temporal?: boolean;
rows?: readonly unknown[];
}): Result<string, LoadedTable> {
const table = parseTable(dto);
if (!table.ok) return table;
return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });
}
function parseColumn(dto: StamdataColumnDto): Result<string, StamColumn> {
if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');
const raw = dto.type ?? '';
const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';
return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });
}
function parseTable(dto: {
id?: string;
label?: string;
columns?: StamdataColumnDto[];
temporal?: boolean;
}): Result<string, StamTable> {
if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))
return err('stamdata table: bad shape');
const columns: StamColumn[] = [];
for (const c of dto.columns) {
const parsed = parseColumn(c);
if (!parsed.ok) return parsed;
columns.push(parsed.value);
}
if (columns.length === 0) return err('stamdata table: no columns');
return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });
}
/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */
function parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {
return rows.map((raw) => {
const row: StamRow = {};
const obj = (raw ?? {}) as Record<string, unknown>;
for (const c of columns) {
const v = obj[c.name];
row[c.name] = v === null || v === undefined ? '' : String(v);
}
return row;
});
}