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