Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill, DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't- validate value objects, infrastructure adapters, plus CLAUDE.md and the architecture/ADR docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Registration, Aantekening } from '../domain/registration';
|
||||
import { Aantekening } from '../domain/registration';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
||||
* resources (Angular's httpResource); 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 {
|
||||
registrationResource() {
|
||||
return httpResource<Registration>(() => 'mock/registration.json');
|
||||
}
|
||||
|
||||
aantekeningenResource() {
|
||||
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Person } from '../domain/person';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
||||
|
||||
/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */
|
||||
/**
|
||||
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
|
||||
* is a static mock; pointing at a real backend touches only this file + the DTO.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrpAdapter {
|
||||
personResource() {
|
||||
return httpResource<Person>(() => 'mock/brp.json');
|
||||
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
|
||||
// parseBrpAddress validates it.
|
||||
adresResource() {
|
||||
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,32 @@
|
||||
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,47 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||
* ONE call returns registration + person + server-computed decisions.
|
||||
* (In this POC the endpoint is a static mock; the decisions are precomputed to
|
||||
* stand in for what the backend would compute.)
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardViewAdapter {
|
||||
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
|
||||
// parseDashboardView validates it at the boundary before the app uses it.
|
||||
dashboardViewResource() {
|
||||
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
return ok({
|
||||
profile: { registration: reg, person },
|
||||
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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,60 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||
|
||||
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
|
||||
|
||||
/**
|
||||
* 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. In
|
||||
* this POC the endpoint is a static mock.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DuoAdapter {
|
||||
diplomasResource() {
|
||||
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
|
||||
}
|
||||
}
|
||||
|
||||
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