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:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user