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>
146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
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 });
|
|
}
|