feat(boundaries): WP-03 — contracts purity + ApiClient confinement
Lint-enforce two architecture rules that were only documented (ADR-0001), landing the rules with the fixes so the build stays green: - contracts/ imports nothing: dashboard-view.dto.ts is now pure wire shapes (inline string-union enums, no domain imports). The DashboardView FE-view type moves to the adapter, which maps wire → domain (compiler-enforced seam). - ApiClient lives only in infrastructure: change-request-form (UI) no longer injects ApiClient — a new ChangeRequestAdapter owns the client and the submit becomes a createSubmitChangeRequest() command factory (createDraftSync shape). draft-sync's wire-DTO import becomes type-only (allowed via allowTypeImports). - Role type moves to shared/domain/role.ts; the ?role= reader stays in shared/infrastructure/role.ts. - eslint: contracts import-ban + @typescript-eslint/no-restricted-imports on api-client (value-only; type imports permitted; infra + shared/upload exempt). Also fixes a PRE-EXISTING bug found while verifying the flow: change-request-form never imported FormsModule, so (ngSubmit) didn't bind and the submit button did a native form submit (page reload) instead of submitting. Verified end-to-end in the running app: submit → command → adapter → backend → reference, success alert shown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { Role, currentRole } from '@shared/infrastructure/role';
|
||||
import { Role } from '@shared/domain/role';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { Role } from '@shared/infrastructure/role';
|
||||
import { Role } from '@shared/domain/role';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
@@ -2,9 +2,9 @@ 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, DashboardView } from '../contracts/dashboard-view.dto';
|
||||
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
|
||||
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
|
||||
import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
|
||||
import { DashboardView, DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
|
||||
import type { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
|
||||
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Valid } from '@registratie/domain/change-request.machine';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
|
||||
|
||||
/**
|
||||
* Command: POST an address change to the backend (`/api/v1/change-requests`),
|
||||
* which re-validates and returns a reference. Same shape as the other submit
|
||||
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
|
||||
* 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 submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
|
||||
return runSubmit(async () => {
|
||||
const res = await client.changeRequests({
|
||||
straat: data.straat,
|
||||
postcode: data.postcode,
|
||||
woonplaats: data.woonplaats,
|
||||
});
|
||||
return res.referentie ?? '';
|
||||
}, SUBMIT_FAILED);
|
||||
export function createSubmitChangeRequest() {
|
||||
const adapter = inject(ChangeRequestAdapter);
|
||||
return (data: Valid): Promise<Result<string, string>> =>
|
||||
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,59 @@
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { Person } from '@registratie/domain/person';
|
||||
import { BigProfile } from '@registratie/domain/big-profile';
|
||||
|
||||
/**
|
||||
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
|
||||
*
|
||||
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
|
||||
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
|
||||
* — never recomputed on the client. The frontend renders decisions; it does not
|
||||
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
* PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are
|
||||
* inlined string-literal unions that describe the wire, not the domain. The
|
||||
* adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto
|
||||
* the FE domain model (Registration/Person/BigProfile) — that map is the
|
||||
* decoupling seam: the wire can change without the domain following.
|
||||
*
|
||||
* In production these types are GENERATED from the OpenAPI/TypeSpec spec (one
|
||||
* source of truth for both sides), and the `decisions` block is computed BY THE
|
||||
* BACKEND — never recomputed on the client. The frontend renders decisions; it
|
||||
* does not own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
|
||||
* so the page always sees one consistent snapshot instead of three independently
|
||||
* loading/erroring resources.
|
||||
*/
|
||||
export interface DashboardViewDto {
|
||||
registration: Registration;
|
||||
person: Person;
|
||||
decisions: HerregistratieDecisions;
|
||||
|
||||
/** Registration status on the wire: the discriminant tags as they arrive. */
|
||||
export type RegistrationStatusDto =
|
||||
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
|
||||
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
|
||||
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
|
||||
|
||||
export interface RegistrationDto {
|
||||
bigNummer: string;
|
||||
naam: string;
|
||||
beroep: string;
|
||||
registratiedatum: string; // ISO date
|
||||
geboortedatum: string;
|
||||
status: RegistrationStatusDto;
|
||||
}
|
||||
|
||||
/** Server-computed decisions. The eligibility rule lives on the backend; the
|
||||
optional reason lets the UI explain itself without knowing the rule. */
|
||||
export interface AdresDto {
|
||||
straat: string;
|
||||
postcode: string;
|
||||
woonplaats: string;
|
||||
}
|
||||
|
||||
export interface PersonDto {
|
||||
naam: string;
|
||||
geboortedatum: string; // ISO date
|
||||
adres: AdresDto;
|
||||
}
|
||||
|
||||
/** Server-computed decisions. Rendered by the FE as-is (decision DTO, ADR-0001):
|
||||
the eligibility rule lives on the backend; the optional reason lets the UI
|
||||
explain itself without knowing the rule. */
|
||||
export interface HerregistratieDecisions {
|
||||
eligibleForHerregistratie: boolean;
|
||||
herregistratieReason?: string;
|
||||
}
|
||||
|
||||
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
|
||||
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
|
||||
change without the FE domain following, and vice-versa. */
|
||||
export interface DashboardView {
|
||||
profile: BigProfile;
|
||||
export interface DashboardViewDto {
|
||||
registration: RegistrationDto;
|
||||
person: PersonDto;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
|
||||
23
src/app/registratie/infrastructure/change-request.adapter.ts
Normal file
23
src/app/registratie/infrastructure/change-request.adapter.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { Valid } from '@registratie/domain/change-request.machine';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the adreswijziging POST (`/api/v1/change-requests`) —
|
||||
* the single place the network client lives for change requests, so the command
|
||||
* and the UI never touch `ApiClient`. Returns the server reference; the server
|
||||
* re-validates and is the authority.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChangeRequestAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async changeRequest(data: Valid): Promise<string> {
|
||||
const res = await this.client.changeRequests({
|
||||
straat: data.straat,
|
||||
postcode: data.postcode,
|
||||
woonplaats: data.woonplaats,
|
||||
});
|
||||
return res.referentie ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
||||
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
|
||||
@@ -48,8 +61,21 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
|
||||
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: reg, person },
|
||||
profile: { registration, person: persoon },
|
||||
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
@@ -6,8 +7,7 @@ import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||
import { submitChangeRequest } from '@registratie/application/submit-change-request';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
|
||||
|
||||
/**
|
||||
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
|
||||
@@ -17,7 +17,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-change-request-form',
|
||||
imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
|
||||
imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
|
||||
template: `
|
||||
@if (state().tag === 'Submitted') {
|
||||
<app-alert type="ok" i18n="@@changeRequest.success">
|
||||
@@ -50,7 +50,10 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
`,
|
||||
})
|
||||
export class ChangeRequestFormComponent {
|
||||
private apiClient = inject(ApiClient);
|
||||
// The submit command owns the ApiClient dependency (via the change-request
|
||||
// adapter); the UI holds only this bound command. Field initializer = injection
|
||||
// context, like createStore below.
|
||||
private submit = createSubmitChangeRequest();
|
||||
private store = createStore<State, Msg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||
@@ -91,7 +94,7 @@ export class ChangeRequestFormComponent {
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
const r = await submitChangeRequest(this.apiClient, s.data);
|
||||
const r = await this.submit(s.data);
|
||||
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
}
|
||||
|
||||
7
src/app/shared/domain/role.ts
Normal file
7
src/app/shared/domain/role.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The two-person letter workflow's role: who is acting, a drafter or an approver.
|
||||
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
|
||||
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
|
||||
* letter-composer) depend on this type, not on how the role is obtained.
|
||||
*/
|
||||
export type Role = 'drafter' | 'approver';
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Dev-only role stand-in. This POC has one faked self-service user and no real
|
||||
* identities, so the two-person letter workflow (drafter vs approver) is driven by
|
||||
* a `?role=` query param — exactly the pattern of the `?scenario=` toggle. The
|
||||
* backend receives it as an `X-Role` header (see role.interceptor) and enforces the
|
||||
* approver≠drafter rule; the FE derives `editable` from it.
|
||||
*/
|
||||
export type Role = 'drafter' | 'approver';
|
||||
import { Role } from '@shared/domain/role';
|
||||
|
||||
/**
|
||||
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
|
||||
* POC has one faked self-service user and no real identities, so the two-person
|
||||
* letter workflow (drafter vs approver) is driven by a `?role=` query param —
|
||||
* exactly the pattern of the `?scenario=` toggle. The backend receives it as an
|
||||
* `X-Role` header (see role.interceptor) and enforces the approver≠drafter rule;
|
||||
* the FE derives `editable` from it.
|
||||
*/
|
||||
export function currentRole(): Role {
|
||||
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user