import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { DashboardViewDto, HerregistratieDecisions, } from '@registratie/contracts/dashboard-view.dto'; import { Registration } from '@registratie/domain/registration'; import { Person } from '@registratie/domain/person'; import { BigProfile } from '@registratie/domain/big-profile'; import { ApiClient } from '@shared/infrastructure/api-client'; /** * The parsed, frontend-side view: the wire DTO mapped onto our own domain model. * Lives HERE, not in contracts/, because it references domain types — contracts * stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001). */ export interface DashboardView { profile: BigProfile; decisions: HerregistratieDecisions; } /** * Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint. * ONE call returns registration + person + server-computed decisions. The data * comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed * client; the decisions (e.g. herregistratie eligibility) are computed there. */ @Injectable({ providedIn: 'root' }) export class DashboardViewAdapter { private client = inject(ApiClient); // The value is still untrusted JSON — parseDashboardView validates it at the // boundary and maps DTO → domain before the app uses it. // // SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here — // e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the // adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls // (the submit-* commands) must NEVER auto-retry — and don't. Manual retry // (resource.reload via ) covers the UX today, so backoff stays unbuilt. dashboardViewResource() { return resource({ loader: () => this.client.dashboardView() }); } } /** * 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 { if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object'); const dto = json as Partial; 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'); } // Map wire → domain. The shapes are identical today, so this reads as an // identity copy — but the TYPES differ (wire DTO vs domain), so the moment the // wire diverges the compiler forces a real mapping here. That's the seam. const registration: Registration = { bigNummer: reg.bigNummer, naam: reg.naam, beroep: reg.beroep, registratiedatum: reg.registratiedatum, geboortedatum: reg.geboortedatum, status: reg.status, }; const persoon: Person = { naam: person.naam, geboortedatum: person.geboortedatum, adres: person.adres, }; return ok({ profile: { registration, person: persoon }, decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason, }, }); }