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,55 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AdminCasesStore } from './admin-cases.store';
const summary = (id: string) => ({
id,
type: 'registratie',
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
documentIds: [],
createdAt: '2026-07-23T10:00:00Z',
updatedAt: '2026-07-23T10:00:00Z',
owner: '19012345601',
});
function setup(adapter: Partial<ApplicationsAdapter>): AdminCasesStore {
TestBed.configureTestingModule({
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
});
return TestBed.inject(AdminCasesStore);
}
describe('AdminCasesStore', () => {
it('loads and parses the cross-owner list', async () => {
const store = setup({ listAll: () => Promise.resolve([summary('a'), summary('b')]) });
await store.load();
const s = store.cases();
expect(s.tag).toBe('Success');
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a', 'b']);
});
it('deletes optimistically and confirms via the admin endpoint', async () => {
const deleteAny = vi.fn().mockResolvedValue(undefined);
const store = setup({
listAll: () => Promise.resolve([summary('a'), summary('b')]),
deleteAny,
});
await store.load();
await store.delete('a');
expect(deleteAny).toHaveBeenCalledWith('a');
const s = store.cases();
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
});
it('rolls back the removal when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
await store.load();
await store.delete('a');
const s = store.cases();
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
});
});
@@ -0,0 +1,57 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined;
/**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic) and rolls back on error. Admin delete removes any case (any owner,
* submitted or not — the server enforces the capability).
*/
@Injectable({ providedIn: 'root' })
export class AdminCasesStore {
private adapter = inject(ApplicationsAdapter);
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly();
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
last-good value on a resync (only shows Loading on the first load). */
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseApplications(await this.adapter.listAll());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
reload() {
void this.load();
}
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
async delete(id: string) {
const before = this.state();
if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
}
try {
await this.adapter.deleteAny(id);
} catch {
this.state.set(before); // roll back: the row reappears
}
}
}
@@ -0,0 +1,65 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined;
/**
* The dashboard's view of the user's applications (aanvragen) — the backend is the
* system of record (PRD 0001). One root singleton OWNS the list as a writable
* RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
* computed server-side on read).
*/
@Injectable({ providedIn: 'root' })
export class ApplicationsStore {
private adapter = inject(ApplicationsAdapter);
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly applications = this.state.asReadonly();
constructor() {
void this.load();
}
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
last-good value on a resync (only shows Loading on the first load). */
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseApplications(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
/** Re-fetch (e.g. on dashboard revisit) so auto-approval transitions show up. */
reload() {
void this.load();
}
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
No resync — the delete succeeded, so the optimistic removal is authoritative. */
async cancel(id: string) {
const before = this.state();
if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
}
try {
await this.adapter.cancel(id);
} catch {
this.state.set(before); // roll back: the block reappears
}
}
}
@@ -0,0 +1,83 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import {
DashboardView,
DashboardViewAdapter,
parseDashboardView,
} from '../infrastructure/dashboard-view.adapter';
type Err = Error | undefined;
/**
* The single source of truth for the logged-in professional's profile, shared
* across pages (providedIn:'root' = one instance). It owns the httpResources
* (created here, in the required injection context) and exposes them as
* RemoteData signals.
*
* The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
* returns registration + person + server-computed `decisions`. One request → one
* consistent snapshot, instead of stitching three independently loading/erroring
* resources together client-side. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
*/
@Injectable({ providedIn: 'root' })
export class BigProfileStore {
private big = inject(BigRegisterAdapter);
private viewAdapter = inject(DashboardViewAdapter);
private viewRes = this.viewAdapter.dashboardViewResource();
private aantekeningenRes = this.big.aantekeningenResource();
/** The aggregated view, validated at the trust boundary (DTO → domain). */
private view = computed<RemoteData<Err, DashboardView>>(() => {
const rd = fromResource(this.viewRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDashboardView(rd.value);
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
/** Registration + person, from the single aggregated call. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
map(this.view(), (v) => v.profile),
);
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() =>
map(this.view(), (v) => v.decisions),
);
/** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
});
// --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false);
/** True while a herregistratie submission is in flight or just submitted. */
readonly pendingHerregistratie = this.pending.asReadonly();
beginHerregistratie() {
this.pending.set(true); // optimistic: show it immediately on the dashboard
}
confirmHerregistratie() {
this.pending.set(false);
this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)
}
rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag
}
// Retry hooks for [data]-fed <app-async> instances (they don't own the resource).
reloadProfile() {
this.viewRes.reload();
}
reloadAantekeningen() {
this.aantekeningenRes.reload();
}
}
@@ -0,0 +1,155 @@
import { ApplicationRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { createDraftSync, DraftSnapshot } from './draft-sync';
function setup(adapter: Partial<ApplicationsAdapter>) {
const navigate = vi.fn().mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
{ provide: ApplicationsAdapter, useValue: adapter },
{ provide: Router, useValue: { navigate } },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
],
});
const snap = signal<DraftSnapshot | null>(null);
const onResume = vi.fn();
const draftSync = TestBed.runInInjectionContext(() =>
createDraftSync({
type: 'registratie',
snapshot: () => snap(),
onResume,
enabled: () => true,
}),
);
TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run
return { draftSync, snap, navigate, onResume };
}
const tick = () => TestBed.inject(ApplicationRef).tick();
describe('createDraftSync', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
// still inside the 600ms debounce window — nothing has synced yet
await vi.advanceTimersByTimeAsync(500);
expect(syncDraft).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(200);
expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three
expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three
expect(syncDraft).toHaveBeenCalledWith(
'a1',
expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins
);
});
it('a trailing change after the debounce fires schedules its own sync', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
await vi.advanceTimersByTimeAsync(600);
expect(syncDraft).toHaveBeenCalledTimes(1);
snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] });
tick();
await vi.advanceTimersByTimeAsync(600);
expect(syncDraft).toHaveBeenCalledTimes(2);
expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 }));
});
describe('submit', () => {
it('resolves ok with the server response on success', async () => {
const create = vi.fn().mockResolvedValue('a1');
const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true });
const { draftSync } = setup({ create, submit });
const r = await draftSync.submit({});
expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } });
expect(submit).toHaveBeenCalledWith('a1', {});
});
it('folds a rejected submit into a Result error, never throwing', async () => {
const create = vi.fn().mockResolvedValue('a1');
const submit = vi.fn().mockRejectedValue(new Error('boom'));
const { draftSync } = setup({ create, submit });
const r = await draftSync.submit({});
expect(r.ok).toBe(false);
});
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => {
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
// and ensureId adopts the existing Concept from the list instead of erroring.
const create = vi.fn().mockRejectedValue({ status: 409 });
const list = vi.fn().mockResolvedValue([
{
id: 'existing-1',
type: 'registratie',
status: { tag: 'Concept', stepIndex: 1, stepCount: 3 },
createdAt: '2026-07-23T10:00:00Z',
updatedAt: '2026-07-23T10:00:00Z',
},
]);
const submit = vi.fn().mockResolvedValue({ id: 'existing-1', autoApprovable: true });
const { draftSync } = setup({ create, list, submit });
const r = await draftSync.submit({});
expect(r.ok).toBe(true);
expect(submit).toHaveBeenCalledWith('existing-1', {}); // adopted, not a new id
});
});
describe('flushPending (CanDeactivate guard / beforeunload)', () => {
it('hasPendingSave reflects an armed debounce timer', () => {
const { draftSync, snap } = setup({
create: vi.fn().mockResolvedValue('a1'),
syncDraft: vi.fn().mockResolvedValue(undefined),
});
expect(draftSync.hasPendingSave()).toBe(false);
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick(); // the effect arms the 600ms timer
expect(draftSync.hasPendingSave()).toBe(true);
});
it('flushPending writes the pending draft immediately, before the debounce fires', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { draftSync, snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
await draftSync.flushPending();
expect(syncDraft).toHaveBeenCalledTimes(1); // no timer advance needed
expect(draftSync.hasPendingSave()).toBe(false); // timer consumed
});
it('flushPending is a no-op when nothing is pending', async () => {
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { draftSync } = setup({ create: vi.fn().mockResolvedValue('a1'), syncDraft });
await draftSync.flushPending();
expect(syncDraft).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,236 @@
import { DestroyRef, effect, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { registerPendingSave } from '@shared/application/pending-saves';
import type {
SubmitApplicationRequest,
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot {
draft: unknown;
stepIndex: number;
stepCount: number;
documentIds: string[];
}
export interface DraftSyncDeps {
type: AanvraagType;
/** The machine snapshot while it's worth persisting; null when not (pristine/done). */
snapshot: () => DraftSnapshot | null;
/** Seed the machine from a resumed draft. Called at most once, on init, and ONLY
with a real draft on a still-pristine machine — see `applyResume`. */
onResume: (draft: unknown) => void;
/** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */
enabled: () => boolean;
}
const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.
/**
* The effectful glue that replaces per-wizard sessionStorage with a backend-owned
* Concept (PRD 0001, phase D). Instantiated in a field initializer (like
* `createStore`/`createUploadController`). Responsibilities:
*
* - resume: a `?aanvraag=<id>` link wins; otherwise resume the ONE existing Concept of
* this type (at most one per type), seeding the machine from its saved draft;
* - create-on-first-progress: when no Concept exists, one is created lazily the first
* time the wizard reports a non-null snapshot, and its id is stamped into the URL;
* - debounced draft sync on every subsequent change.
*
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
*/
export function createDraftSync(deps: DraftSyncDeps) {
const adapter = inject(ApplicationsAdapter);
const router = inject(Router, { optional: true });
const route = inject(ActivatedRoute, { optional: true });
const active = () => deps.enabled() && !!router && !!route;
let id: string | undefined;
let ensuring: Promise<string> | undefined; // in-flight create, so we never create twice
let timer: ReturnType<typeof setTimeout> | undefined;
// Resolves once resume() has decided whether a Concept of this type already exists;
// gates ensureId so a fast typist can't create a duplicate before that lookup lands.
let resumeGate: Promise<unknown> = Promise.resolve();
const ensureId = async (): Promise<string> => {
await resumeGate;
if (id) return id;
ensuring ??= adapter
.create(deps.type)
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate
// already prevents a second create, but a cross-tab/stale race can still hit the
// server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure.
.catch(async (e) => {
const existing = await findConcept();
if (existing) return existing;
throw e;
})
.then((newId) => {
id = newId;
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: newId },
queryParamsHandling: 'merge',
replaceUrl: true,
});
return newId;
});
return ensuring;
};
// Apply a resumed draft only when it's safe to: a late lookup must never clobber
// progress the user already made while it was in flight, and "start fresh" needs no
// dispatch (the machine already starts fresh). snapshot() is non-null once the user
// has real progress.
const applyResume = (draft: unknown | null) => {
if (draft == null || deps.snapshot() != null) return;
deps.onResume(draft);
};
const flush = async () => {
const snap = deps.snapshot();
if (!snap) return;
const theId = await ensureId();
await adapter.syncDraft(theId, {
draft: snap.draft,
stepIndex: snap.stepIndex,
stepCount: snap.stepCount,
documentIds: snap.documentIds,
});
};
// One effect watches the snapshot; each change resets a debounce timer. The timer's
// callback only does network I/O (never dispatch), so it can't livelock the store.
effect(() => {
if (!active()) return;
const snap = deps.snapshot(); // tracked: fires on every machine change
if (!snap) return;
if (timer) clearTimeout(timer);
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
timer = setTimeout(() => {
timer = undefined;
void flush();
}, DEBOUNCE_MS);
});
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
// Flush a pending debounced draft write before an in-app route change / unload (see
// pending-saves.ts). onDestroy above only cancels the timer — this actually persists it.
const hasPendingSave = () => timer !== undefined;
const flushPending = async () => {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
await flush();
};
registerPendingSave({ hasPendingSave, flushPending });
// Attach to a specific Concept id and seed the machine from its draft. A non-Concept
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
const load = (linked: string): Promise<void> => {
id = linked;
return adapter
.detail(linked)
.then((dto) => {
if (dto.status && dto.status.tag !== 'Concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(dto.draft ?? null);
})
.catch(() => {
id = undefined;
applyResume(null); // unknown/deleted id → start fresh
});
};
// Find the user's existing Concept of this type (at most one), if any.
const findConcept = async (): Promise<string | undefined> => {
try {
const parsed = parseApplications(await adapter.list());
return parsed.ok
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
: undefined;
} catch {
return undefined;
}
};
return {
/** True while a debounced draft write is still pending (PendingSave). */
hasPendingSave,
/** Flush the pending draft write now and await it; no-op when nothing is pending. */
flushPending,
/** Resolve the initial state: a `?aanvraag` link wins; else resume this type's
existing Concept; else start fresh (a Concept is created on first progress). */
async resume() {
let release!: () => void;
resumeGate = new Promise<void>((r) => (release = r));
try {
if (!active()) {
applyResume(null);
return;
}
const linked = route!.snapshot.queryParamMap.get('aanvraag');
if (linked) {
await load(linked);
return;
}
const existing = await findConcept();
if (existing) {
await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept.
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: existing },
queryParamsHandling: 'merge',
replaceUrl: true,
});
return;
}
applyResume(null);
} finally {
release();
}
},
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
`POST /applications/{id}/submit` (server sets autoApprovable + transitions).
Folded into a Result like the old submit-* commands. */
submit(body: SubmitApplicationRequest): Promise<Result<string, SubmitApplicationResponse>> {
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
},
/** Restart: discard the current in-progress Concept (delete it) and detach, so a
fresh one is created on next progress. Keeps the one-per-type invariant. A
submitted id can't be deleted (409, caught) — that submission correctly remains,
and detaching still lets the user start a new Concept. */
reset() {
if (id) {
void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept
id = undefined;
ensuring = undefined;
}
if (active())
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: null },
queryParamsHandling: 'merge',
replaceUrl: true,
});
},
};
}
@@ -0,0 +1,62 @@
import { Injectable, computed, inject } from '@angular/core';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { DuoLookupDto } from '../contracts/duo-diplomas.dto';
import { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter';
type Err = Error | undefined;
/**
* Application-layer facade for the registratie wizard's two lookups (BRP address,
* DUO diplomas). It owns the httpResources (created here, in the required injection
* context), runs the trust-boundary parse, and exposes RemoteData / derived signals
* — so the UI reaches the network through application/, never infrastructure/
* directly (CLAUDE.md §1: ui → application → domain). Same facade shape as
* BigProfileStore.
*/
@Injectable({ providedIn: 'root' })
export class RegistratieLookupStore {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
readonly adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The address to prefill the draft with, once BRP resolves with a found address;
null otherwise (loading, error, no address, malformed). */
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(
() => {
const json = this.adresRes.value();
if (json === undefined) return null;
const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
},
);
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
reloadAdres() {
this.adresRes.reload();
}
}
@@ -0,0 +1,42 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Valid } from '@registratie/domain/change-request.machine';
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
import { createSubmitChangeRequest } from './submit-change-request';
import { parseTelefoonnummer } from '@registratie/domain/value-objects/telefoonnummer';
const telefoon = parseTelefoonnummer('0612345678');
if (!telefoon.ok) throw new Error('fixture phone should parse');
const data: Valid = { telefoon: telefoon.value };
function setup(adapter: Partial<ChangeRequestAdapter>) {
TestBed.configureTestingModule({
providers: [{ provide: ChangeRequestAdapter, useValue: adapter }],
});
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
}
describe('createSubmitChangeRequest', () => {
it('resolves ok with the referentie on success', async () => {
const submit = setup({ changeRequest: () => Promise.resolve('BIG-2026-000123') });
const r = await submit(data);
expect(r).toEqual({ ok: true, value: 'BIG-2026-000123' });
});
it('folds a rejected call into a Result error, never throwing', async () => {
const submit = setup({
changeRequest: () => Promise.reject(new Error('network kaput')),
});
const r = await submit(data);
expect(r.ok).toBe(false);
});
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
const submit = setup({
changeRequest: () => Promise.reject({ detail: 'Telefoonnummer is ongeldig.' }),
});
const r = await submit(data);
expect(r).toEqual({ ok: false, error: 'Telefoonnummer is ongeldig.' });
});
});
@@ -0,0 +1,18 @@
import { inject } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@registratie/domain/change-request.machine';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
/**
* Command factory: binds the change-request adapter (which owns the `ApiClient`)
* in an injection context and returns the submit function the form calls. Same
* field-initializer shape as `createStore`/`createDraftSync`, so the UI holds an
* application command — not the network client. Returns a `Result`, never a thrown
* error, so the form's reduce can branch on the outcome.
*/
export function createSubmitChangeRequest() {
const adapter = inject(ChangeRequestAdapter);
return (data: Valid): Promise<Result<string, string>> =>
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
}