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,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAanvraagStatus,
|
||||
parseApplicationSummary,
|
||||
parseApplications,
|
||||
parseApplicationDetail,
|
||||
} from './applications.adapter';
|
||||
|
||||
const concept = {
|
||||
id: 'a1',
|
||||
type: 'registratie',
|
||||
status: { tag: 'Concept', stepIndex: 1, stepCount: 4 },
|
||||
documentIds: [],
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
updatedAt: '2026-07-01T10:05:00Z',
|
||||
};
|
||||
|
||||
describe('parseAanvraagStatus', () => {
|
||||
it('parses each tag with its required fields', () => {
|
||||
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({
|
||||
ok: true,
|
||||
value: { tag: 'Concept', stepIndex: 2, stepCount: 4 },
|
||||
});
|
||||
expect(parseAanvraagStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'diploma?' }).ok,
|
||||
).toBe(true);
|
||||
expect(parseAanvraagStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
|
||||
expect(parseAanvraagStatus(undefined).ok).toBe(false);
|
||||
expect(parseAanvraagStatus({ tag: 'Onzin' }).ok).toBe(false);
|
||||
expect(parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false); // manual missing
|
||||
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 1 }).ok).toBe(false); // stepCount missing
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseApplicationSummary', () => {
|
||||
it('maps a valid DTO to domain', () => {
|
||||
const r = parseApplicationSummary(concept);
|
||||
expect(r.ok && r.value.type).toBe('registratie');
|
||||
expect(r.ok && r.value.status.tag).toBe('Concept');
|
||||
});
|
||||
|
||||
it('rejects a bad type and non-objects', () => {
|
||||
expect(parseApplicationSummary({ ...concept, type: 'onbekend' }).ok).toBe(false);
|
||||
expect(parseApplicationSummary(null).ok).toBe(false);
|
||||
expect(parseApplicationSummary({ ...concept, id: 42 }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseApplications / parseApplicationDetail', () => {
|
||||
it('parses a list and fails fast on a bad element', () => {
|
||||
expect(parseApplications([concept, concept]).ok).toBe(true);
|
||||
expect(parseApplications([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false);
|
||||
expect(parseApplications({}).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the opaque draft through detail', () => {
|
||||
const r = parseApplicationDetail({ ...concept, draft: { beroep: 'arts' } });
|
||||
expect(r.ok && (r.value.draft as { beroep: string }).beroep).toBe('arts');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
ApiClient,
|
||||
AanvraagStatusDto,
|
||||
ApplicationSummaryDto,
|
||||
ApplicationDetailDto,
|
||||
DraftSyncRequest,
|
||||
SubmitApplicationRequest,
|
||||
SubmitApplicationResponse,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import {
|
||||
Aanvraag,
|
||||
AanvraagDetail,
|
||||
AanvraagStatus,
|
||||
AanvraagType,
|
||||
} from '@registratie/domain/aanvraag';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place
|
||||
* its HTTP lives (ADR-0001 anti-corruption boundary). The list is a resource; the
|
||||
* mutations (create/sync/cancel/submit) are thin commands the ApplicationsStore
|
||||
* orchestrates optimistically. The untrusted response is validated + mapped to
|
||||
* domain by the hand-written parse* boundary below.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApplicationsAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
/** The dashboard's application list (raw DTOs; the store parses at the boundary). */
|
||||
list(): Promise<ApplicationSummaryDto[]> {
|
||||
return this.client.applicationsAll();
|
||||
}
|
||||
|
||||
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */
|
||||
listAll(): Promise<ApplicationSummaryDto[]> {
|
||||
return this.client.casesAll();
|
||||
}
|
||||
|
||||
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */
|
||||
deleteAny(id: string): Promise<void> {
|
||||
return this.client.cases(id);
|
||||
}
|
||||
|
||||
detail(id: string): Promise<ApplicationDetailDto> {
|
||||
return this.client.applicationsGET(id);
|
||||
}
|
||||
|
||||
/** Create a Concept for a wizard type; resolves to the new aanvraag id. */
|
||||
create(type: AanvraagType): Promise<string> {
|
||||
return this.client.applicationsPOST({ type }).then((d) => d.id ?? '');
|
||||
}
|
||||
|
||||
/** Draft sync per step (idempotent). Keep it debounced at the call site — it is chatty. */
|
||||
syncDraft(id: string, body: DraftSyncRequest): Promise<void> {
|
||||
return this.client.applicationsPUT(id, body);
|
||||
}
|
||||
|
||||
/** Cancel a Concept (cascades to its unlinked documents server-side). */
|
||||
cancel(id: string): Promise<void> {
|
||||
return this.client.applicationsDELETE(id);
|
||||
}
|
||||
|
||||
submit(id: string, body: SubmitApplicationRequest): Promise<SubmitApplicationResponse> {
|
||||
return this.client.submit(id, body);
|
||||
}
|
||||
}
|
||||
|
||||
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
||||
|
||||
/** Trust-boundary parse of the status union — the tag drives which fields must exist. */
|
||||
export function parseAanvraagStatus(
|
||||
s: AanvraagStatusDto | undefined,
|
||||
): Result<string, AanvraagStatus> {
|
||||
if (!s || typeof s.tag !== 'string') return err('aanvraag: missing status');
|
||||
switch (s.tag) {
|
||||
case 'Concept':
|
||||
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number')
|
||||
return err('aanvraag: bad Concept status');
|
||||
return ok({ tag: 'Concept', stepIndex: s.stepIndex, stepCount: s.stepCount });
|
||||
case 'Ingediend':
|
||||
if (typeof s.referentie !== 'string') return err('aanvraag: bad Ingediend status');
|
||||
return ok({ tag: 'Ingediend', referentie: s.referentie });
|
||||
case 'InBehandeling':
|
||||
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
|
||||
return err('aanvraag: bad InBehandeling status');
|
||||
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
|
||||
case 'MeerInfoGevraagd':
|
||||
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
|
||||
return err('aanvraag: bad MeerInfoGevraagd status');
|
||||
return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden });
|
||||
case 'Goedgekeurd':
|
||||
if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status');
|
||||
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
|
||||
case 'Afgewezen':
|
||||
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
|
||||
return err('aanvraag: bad Afgewezen status');
|
||||
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
|
||||
default:
|
||||
return err(`aanvraag: unknown status tag ${s.tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> {
|
||||
if (typeof dto.id !== 'string') return err('aanvraag: missing id');
|
||||
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
|
||||
return err(`aanvraag: bad type ${dto.type}`);
|
||||
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string')
|
||||
return err('aanvraag: missing timestamps');
|
||||
const status = parseAanvraagStatus(dto.status);
|
||||
if (!status.ok) return status;
|
||||
return ok({
|
||||
id: dto.id,
|
||||
type: dto.type as AanvraagType,
|
||||
status: status.value,
|
||||
documentIds: dto.documentIds ?? [],
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
submittedAt: dto.submittedAt,
|
||||
owner: dto.owner, // only present on the admin cross-owner list (WP-36)
|
||||
});
|
||||
}
|
||||
|
||||
export function parseApplicationSummary(json: unknown): Result<string, Aanvraag> {
|
||||
if (typeof json !== 'object' || json === null) return err('aanvraag: not an object');
|
||||
return parseCommon(json as ApplicationSummaryDto);
|
||||
}
|
||||
|
||||
export function parseApplications(json: unknown): Result<string, Aanvraag[]> {
|
||||
if (!Array.isArray(json)) return err('aanvragen: not an array');
|
||||
const out: Aanvraag[] = [];
|
||||
for (const item of json) {
|
||||
const parsed = parseApplicationSummary(item);
|
||||
if (!parsed.ok) return parsed;
|
||||
out.push(parsed.value);
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
|
||||
export function parseApplicationDetail(json: unknown): Result<string, AanvraagDetail> {
|
||||
if (typeof json !== 'object' || json === null) return err('aanvraag: not an object');
|
||||
const base = parseCommon(json as ApplicationDetailDto);
|
||||
if (!base.ok) return base;
|
||||
return ok({ ...base.value, draft: (json as ApplicationDetailDto).draft ?? null });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAantekening } from './big-register.adapter';
|
||||
|
||||
describe('big-register.adapter parse boundary', () => {
|
||||
it('parses known aantekening types', () => {
|
||||
expect(
|
||||
parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
|
||||
});
|
||||
expect(parseAantekening({ type: 'Aantekening' })).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'Aantekening', omschrijving: '', datum: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown type', () => {
|
||||
expect(parseAantekening({ type: 'Bogus' }).ok).toBe(false);
|
||||
expect(parseAantekening({}).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Aantekening, AantekeningType } from '../domain/registration';
|
||||
import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
||||
* resources (Angular's `resource` over the generated typed client); each returns
|
||||
* a Resource with status()/value()/error()/reload(). Call from an injection
|
||||
* context (a field initializer in the store).
|
||||
*
|
||||
* Note: registration + person are now served via the aggregated dashboard-view
|
||||
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BigRegisterAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
aantekeningenResource() {
|
||||
return resource({
|
||||
loader: () =>
|
||||
this.client.notes().then((ns) => {
|
||||
const out: Aantekening[] = [];
|
||||
for (const n of ns) {
|
||||
const parsed = parseAantekening(n);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
out.push(parsed.value);
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
|
||||
|
||||
/** Trust-boundary parse: an unrecognized type is an explicit Failure, never a silent cast. */
|
||||
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
|
||||
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
|
||||
return err(`aantekening: unknown type ${n.type}`);
|
||||
return ok({
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBrpAddress } from './brp.adapter';
|
||||
|
||||
describe('parseBrpAddress (trust boundary)', () => {
|
||||
it('accepts a found address', () => {
|
||||
const r = parseBrpAddress({
|
||||
gevonden: true,
|
||||
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
|
||||
});
|
||||
|
||||
it('accepts "geen adres" (gevonden: false) as a valid outcome', () => {
|
||||
const r = parseBrpAddress({ gevonden: false });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed responses', () => {
|
||||
expect(parseBrpAddress(null).ok).toBe(false);
|
||||
expect(parseBrpAddress({}).ok).toBe(false); // missing gevonden
|
||||
expect(parseBrpAddress({ gevonden: true }).ok).toBe(false); // found but no adres
|
||||
expect(parseBrpAddress({ gevonden: true, adres: { straat: 'x' } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET
|
||||
* backend (`GET /api/brp/address`) via the generated typed client.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrpAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
// The value is untrusted JSON until parseBrpAddress validates it.
|
||||
adresResource() {
|
||||
return resource({ loader: () => this.client.address() });
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
|
||||
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
|
||||
reach for a schema lib once the contract count grows. */
|
||||
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
|
||||
if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
|
||||
const dto = json as Partial<BrpAddressDto>;
|
||||
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
|
||||
if (dto.gevonden) {
|
||||
const a = dto.adres;
|
||||
if (
|
||||
!a ||
|
||||
typeof a.straat !== 'string' ||
|
||||
typeof a.postcode !== 'string' ||
|
||||
typeof a.woonplaats !== 'string'
|
||||
) {
|
||||
return err('brp-address: missing/invalid adres');
|
||||
}
|
||||
}
|
||||
return ok({ gevonden: dto.gevonden, adres: dto.adres });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { Valid } from '@registratie/domain/change-request.machine';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
|
||||
* the single place the network client lives for contact changes, so the command
|
||||
* and the UI never touch `ApiClient`. The BRP address is authoritative and not
|
||||
* submitted (WP-34); only the phone number is. Returns the server reference; the
|
||||
* server re-validates and is the authority.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChangeRequestAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async changeRequest(data: Valid): Promise<string> {
|
||||
const res = await this.client.changeRequests({ telefoon: data.telefoon });
|
||||
return res.referentie ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDashboardView } from './dashboard-view.adapter';
|
||||
|
||||
const valid = {
|
||||
registration: {
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Dr. A. de Vries',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
|
||||
},
|
||||
person: {
|
||||
naam: 'Dr. A. de Vries',
|
||||
geboortedatum: '1985-03-14',
|
||||
adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
|
||||
},
|
||||
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
|
||||
};
|
||||
|
||||
describe('parseDashboardView (trust boundary)', () => {
|
||||
it('maps a valid response into a DashboardView', () => {
|
||||
const r = parseDashboardView(valid);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.profile.registration.bigNummer).toBe('19012345601');
|
||||
expect(r.value.decisions.eligibleForHerregistratie).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseDashboardView(null).ok).toBe(false);
|
||||
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
|
||||
expect(
|
||||
parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
DashboardViewDto,
|
||||
HerregistratieDecisions,
|
||||
} from '@registratie/contracts/dashboard-view.dto';
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { Person } from '@registratie/domain/person';
|
||||
import { BigProfile } from '@registratie/domain/big-profile';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
/**
|
||||
* The parsed, frontend-side view: the wire DTO mapped onto our own domain model.
|
||||
* Lives HERE, not in contracts/, because it references domain types — contracts
|
||||
* stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001).
|
||||
*/
|
||||
export interface DashboardView {
|
||||
profile: BigProfile;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||
* ONE call returns registration + person + server-computed decisions. The data
|
||||
* comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed
|
||||
* client; the decisions (e.g. herregistratie eligibility) are computed there.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardViewAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
// The value is still untrusted JSON — parseDashboardView validates it at the
|
||||
// boundary and maps DTO → domain before the app uses it.
|
||||
//
|
||||
// SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here —
|
||||
// e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the
|
||||
// adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls
|
||||
// (the submit-* commands) must NEVER auto-retry — and don't. Manual retry
|
||||
// (resource.reload via <app-async>) covers the UX today, so backoff stays unbuilt.
|
||||
dashboardViewResource() {
|
||||
return resource({ loader: () => this.client.dashboardView() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse: validate the untrusted response shape and map the DTO
|
||||
* onto our own domain model. Hand-written on purpose — no Zod for a single
|
||||
* contract. ponytail: reach for a schema lib once the contract count grows.
|
||||
*/
|
||||
export function parseDashboardView(json: unknown): Result<string, DashboardView> {
|
||||
if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');
|
||||
const dto = json as Partial<DashboardViewDto>;
|
||||
|
||||
const reg = dto.registration;
|
||||
if (
|
||||
!reg ||
|
||||
typeof reg.bigNummer !== 'string' ||
|
||||
!reg.status ||
|
||||
typeof reg.status.tag !== 'string'
|
||||
) {
|
||||
return err('dashboard-view: missing/invalid registration');
|
||||
}
|
||||
const person = dto.person;
|
||||
if (!person || !person.adres || typeof person.adres.postcode !== 'string') {
|
||||
return err('dashboard-view: missing/invalid person');
|
||||
}
|
||||
const d = dto.decisions;
|
||||
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
|
||||
return err('dashboard-view: missing/invalid decisions');
|
||||
}
|
||||
|
||||
// Map wire → domain. The shapes are identical today, so this reads as an
|
||||
// identity copy — but the TYPES differ (wire DTO vs domain), so the moment the
|
||||
// wire diverges the compiler forces a real mapping here. That's the seam.
|
||||
const registration: Registration = {
|
||||
bigNummer: reg.bigNummer,
|
||||
naam: reg.naam,
|
||||
beroep: reg.beroep,
|
||||
registratiedatum: reg.registratiedatum,
|
||||
geboortedatum: reg.geboortedatum,
|
||||
status: reg.status,
|
||||
};
|
||||
const persoon: Person = {
|
||||
naam: person.naam,
|
||||
geboortedatum: person.geboortedatum,
|
||||
adres: person.adres,
|
||||
};
|
||||
|
||||
return ok({
|
||||
profile: { registration, person: persoon },
|
||||
decisions: {
|
||||
eligibleForHerregistratie: d.eligibleForHerregistratie,
|
||||
herregistratieReason: d.herregistratieReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDuoLookup } from './duo.adapter';
|
||||
|
||||
const valid = {
|
||||
diplomas: [
|
||||
{
|
||||
id: 'd1',
|
||||
naam: 'Geneeskunde',
|
||||
instelling: 'Universiteit Leiden',
|
||||
jaar: 2011,
|
||||
beroep: 'Arts',
|
||||
policyQuestions: [],
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
naam: 'Medicine',
|
||||
instelling: 'University of Edinburgh',
|
||||
jaar: 2013,
|
||||
beroep: 'Arts',
|
||||
policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }],
|
||||
},
|
||||
],
|
||||
handmatig: {
|
||||
beroepen: ['Arts', 'Verpleegkundige'],
|
||||
policyQuestions: [{ id: 'toelichting', vraag: 'Toelichting', type: 'tekst' }],
|
||||
},
|
||||
};
|
||||
|
||||
describe('parseDuoLookup (trust boundary)', () => {
|
||||
it('maps a valid lookup (diplomas + manual fallback)', () => {
|
||||
const r = parseDuoLookup(valid);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.diplomas).toHaveLength(2);
|
||||
expect(r.value.diplomas[1].policyQuestions[0].id).toBe('nl-taal');
|
||||
expect(r.value.handmatig.beroepen).toContain('Verpleegkundige');
|
||||
expect(r.value.handmatig.policyQuestions[0].type).toBe('tekst');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an empty diploma list (forces manual entry)', () => {
|
||||
const r = parseDuoLookup({ diplomas: [], handmatig: valid.handmatig });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed responses', () => {
|
||||
expect(parseDuoLookup(null).ok).toBe(false);
|
||||
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
|
||||
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
|
||||
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
|
||||
expect(
|
||||
parseDuoLookup({
|
||||
diplomas: [],
|
||||
handmatig: {
|
||||
beroepen: ['Arts'],
|
||||
policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }],
|
||||
},
|
||||
}).ok,
|
||||
).toBe(false); // bad question type
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
DuoLookupDto,
|
||||
DuoDiplomaDto,
|
||||
PolicyQuestionDto,
|
||||
ManualDiplomaPolicyDto,
|
||||
} from '@registratie/contracts/duo-diplomas.dto';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
||||
* user's diplomas (each with its server-computed beroep + policy questions) and
|
||||
* the manual-entry fallback policy. The frontend renders; it does not derive.
|
||||
* Data comes from the .NET backend (`GET /api/duo/diplomas`) via the typed client.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DuoAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
diplomasResource() {
|
||||
return resource({ loader: () => this.client.diplomas() });
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
|
||||
if (!Array.isArray(json)) return null;
|
||||
const out: PolicyQuestionDto[] = [];
|
||||
for (const q of json) {
|
||||
if (typeof q !== 'object' || q === null) return null;
|
||||
const p = q as Partial<PolicyQuestionDto>;
|
||||
if (
|
||||
typeof p.id !== 'string' ||
|
||||
typeof p.vraag !== 'string' ||
|
||||
(p.type !== 'ja-nee' && p.type !== 'tekst')
|
||||
)
|
||||
return null;
|
||||
out.push({ id: p.id, vraag: p.vraag, type: p.type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: validate the untrusted response shape (diplomas, each
|
||||
with derived beroep + policy questions, plus the manual-entry fallback). */
|
||||
export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
|
||||
if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object');
|
||||
const dto = json as Partial<DuoLookupDto>;
|
||||
if (!Array.isArray(dto.diplomas)) return err('duo-lookup: missing diplomas');
|
||||
|
||||
const diplomas: DuoDiplomaDto[] = [];
|
||||
for (const item of dto.diplomas) {
|
||||
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
|
||||
const d = item as Partial<DuoDiplomaDto>;
|
||||
const vragen = parseQuestions(d.policyQuestions);
|
||||
if (
|
||||
typeof d.id !== 'string' ||
|
||||
typeof d.naam !== 'string' ||
|
||||
typeof d.beroep !== 'string' ||
|
||||
vragen === null
|
||||
) {
|
||||
return err('duo-lookup: missing/invalid diploma fields');
|
||||
}
|
||||
diplomas.push({
|
||||
id: d.id,
|
||||
naam: d.naam,
|
||||
instelling: d.instelling ?? '',
|
||||
jaar: typeof d.jaar === 'number' ? d.jaar : 0,
|
||||
beroep: d.beroep,
|
||||
policyQuestions: vragen,
|
||||
});
|
||||
}
|
||||
|
||||
const hm = dto.handmatig;
|
||||
const hmVragen = hm ? parseQuestions(hm.policyQuestions) : null;
|
||||
if (!hm || !Array.isArray(hm.beroepen) || hmVragen === null) {
|
||||
return err('duo-lookup: missing/invalid handmatig fallback');
|
||||
}
|
||||
const handmatig: ManualDiplomaPolicyDto = { beroepen: hm.beroepen, policyQuestions: hmVragen };
|
||||
|
||||
return ok({ diplomas, handmatig });
|
||||
}
|
||||
Reference in New Issue
Block a user