Mijn aanvragen (C): FE contracts + applications adapter + parse boundary

- Regenerate the NSwag client against the new backend endpoints (application +
  content methods, Aanvraag DTOs) — clears the API-client drift.
- registratie/domain/aanvraag.ts: FE domain view — AanvraagType + AanvraagStatus
  discriminated union (illegal states unrepresentable) + Aanvraag/AanvraagDetail.
  Lives in registratie: the dashboard consumes it, downstream wizards produce it.
- ApplicationsAdapter (infrastructure, the only new network surface): list resource
  + create/syncDraft/cancel/submit commands, with a hand-written parse* boundary
  (parseAanvraagStatus/parseApplicationSummary/parseApplications/parseApplicationDetail)
  mapping untrusted DTO -> domain, per ADR-0001. Spec covers each status tag + rejects.
- UploadAdapter.contentUrl(id): direct href for preview/download (browser opens it).

Gates green: dotnet test 56, vitest 122, lint, build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:41:57 +02:00
parent 7b6aac394b
commit 6f250cd987
6 changed files with 955 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
/**
* An application (aanvraag) as the frontend sees it — the parsed, domain-side view
* of the backend-owned aggregate (see backend ApplicationStore + PRD 0001). Pure
* types, no Angular. Lives in `registratie` because the dashboard (here) is the
* consumer and the downstream wizards (`herregistratie → registratie`) produce them.
*
* The status is a discriminated union so illegal states are unrepresentable — same
* reflex as RemoteData. The server computes which tag applies (auto-approval on
* read); the FE renders it, it does not recompute the lifecycle.
*/
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
export type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number }
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt handmatig beoordeeld"
| { tag: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: string };
export interface Aanvraag {
id: string;
type: AanvraagType;
status: AanvraagStatus;
documentIds: string[];
createdAt: string;
updatedAt: string;
submittedAt?: string;
}
/** Detail adds the opaque wizard snapshot used to resume a Concept. */
export interface AanvraagDetail extends Aanvraag {
draft: unknown;
}

View File

@@ -0,0 +1,47 @@
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: 'InBehandeling', referentie: 'BIG-1', manual: true }).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');
});
});

View File

@@ -0,0 +1,115 @@
import { Injectable, inject, resource } 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). */
applicationsResource() {
return resource({ loader: () => this.client.applicationsAll() });
}
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 '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 '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,
});
}
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 });
}