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:
@@ -42,7 +42,7 @@ for its existing violations, so every WP ends green.
|
|||||||
|----|-------|-------|--------|
|
|----|-------|-------|--------|
|
||||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | todo |
|
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
|
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
|
||||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
|
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
|
||||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
|
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-03 — Boundaries I: contracts purity + ApiClient confinement
|
# WP-03 — Boundaries I: contracts purity + ApiClient confinement
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 0 — enforcement & gates
|
Phase: 0 — enforcement & gates
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -56,11 +56,14 @@ lint-enforced. Rule + fixes land together so this WP ends green.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `dashboard-view.dto.ts` has no import statements.
|
- [x] `dashboard-view.dto.ts` has no import statements.
|
||||||
- [ ] `grep -rn "api-client" src/app --include="*.ts" | grep -v infrastructure` returns
|
- [x] No value import of `@shared/infrastructure/api-client` outside `infrastructure/`
|
||||||
only the generated client itself (and its provider wiring, if outside infra).
|
(the only remaining non-infra reference is draft-sync's type-only DTO import,
|
||||||
- [ ] Both eslint rules active; a planted violation fails lint.
|
allowed by `allowTypeImports`). ApiClient confinement is lint-enforced.
|
||||||
- [ ] Change-request flow works (existing machine/command specs pass).
|
- [x] Both eslint rules active; planted violations fail lint (contracts→domain,
|
||||||
|
UI→ApiClient value); type-only DTO import still passes.
|
||||||
|
- [x] Change-request flow works — verified end-to-end in the running app (submit →
|
||||||
|
command → adapter → backend → reference `BIG-2026-…`, success alert); specs green.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
4081
documentation.json
4081
documentation.json
File diff suppressed because one or more lines are too long
@@ -92,4 +92,55 @@ export default [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// contracts/ is the FE⇄BE wire seam: pure DTO shapes that must import NOTHING
|
||||||
|
// (CLAUDE.md §1, ADR-0001) — not Angular, not a context alias, not relative app
|
||||||
|
// code. Enums are inlined string-literal unions; the adapter's parse* maps them.
|
||||||
|
// (This comes after the per-context rules so it wins for contracts files.)
|
||||||
|
{
|
||||||
|
files: ['src/app/**/contracts/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['@angular/**', '@shared/**', '@auth/**', '@registratie/**', '@herregistratie/**', '@brief/**', './*', '../*', './**', '../**'],
|
||||||
|
message: 'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// BFF-lite anti-corruption boundary (ADR-0001): the ApiClient (the network
|
||||||
|
// client) may be imported as a VALUE only from infrastructure-role files.
|
||||||
|
// Type-only imports of generated wire DTOs are allowed anywhere — they grant no
|
||||||
|
// network access. UI/application reach the network through an adapter or command.
|
||||||
|
{
|
||||||
|
files: ['src/app/**/*.ts'],
|
||||||
|
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['@shared/infrastructure/api-client'],
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: 'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// …the infrastructure adapters ARE that boundary and own the client. shared/upload
|
||||||
|
// is a feature-scoped adapter that lives outside a /infrastructure/ folder.
|
||||||
|
{
|
||||||
|
files: ['src/app/**/infrastructure/**/*.ts', 'src/app/shared/upload/**/*.ts'],
|
||||||
|
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||||
|
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { createStore } from '@shared/application/store';
|
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 { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
|
||||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||||
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
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 { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.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 { RemoteData, fromResource, map } from '@shared/application/remote-data';
|
||||||
import { Aantekening } from '../domain/registration';
|
import { Aantekening } from '../domain/registration';
|
||||||
import { BigProfile } from '../domain/big-profile';
|
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 { 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;
|
type Err = Error | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DestroyRef, effect, inject } from '@angular/core';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
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 { AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Valid } from '@registratie/domain/change-request.machine';
|
import { Valid } from '@registratie/domain/change-request.machine';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
|
||||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
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`),
|
* Command factory: binds the change-request adapter (which owns the `ApiClient`)
|
||||||
* which re-validates and returns a reference. Same shape as the other submit
|
* in an injection context and returns the submit function the form calls. Same
|
||||||
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
|
* 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>> {
|
export function createSubmitChangeRequest() {
|
||||||
return runSubmit(async () => {
|
const adapter = inject(ChangeRequestAdapter);
|
||||||
const res = await client.changeRequests({
|
return (data: Valid): Promise<Result<string, string>> =>
|
||||||
straat: data.straat,
|
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
|
||||||
postcode: data.postcode,
|
|
||||||
woonplaats: data.woonplaats,
|
|
||||||
});
|
|
||||||
return res.referentie ?? '';
|
|
||||||
}, 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.
|
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
|
||||||
*
|
*
|
||||||
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
|
* PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are
|
||||||
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
|
* inlined string-literal unions that describe the wire, not the domain. The
|
||||||
* — never recomputed on the client. The frontend renders decisions; it does not
|
* adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto
|
||||||
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
* 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 + …),
|
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
|
||||||
* so the page always sees one consistent snapshot instead of three independently
|
* so the page always sees one consistent snapshot instead of three independently
|
||||||
* loading/erroring resources.
|
* loading/erroring resources.
|
||||||
*/
|
*/
|
||||||
export interface DashboardViewDto {
|
|
||||||
registration: Registration;
|
/** Registration status on the wire: the discriminant tags as they arrive. */
|
||||||
person: Person;
|
export type RegistrationStatusDto =
|
||||||
decisions: HerregistratieDecisions;
|
| { 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
|
export interface AdresDto {
|
||||||
optional reason lets the UI explain itself without knowing the rule. */
|
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 {
|
export interface HerregistratieDecisions {
|
||||||
eligibleForHerregistratie: boolean;
|
eligibleForHerregistratie: boolean;
|
||||||
herregistratieReason?: string;
|
herregistratieReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
|
export interface DashboardViewDto {
|
||||||
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
|
registration: RegistrationDto;
|
||||||
change without the FE domain following, and vice-versa. */
|
person: PersonDto;
|
||||||
export interface DashboardView {
|
|
||||||
profile: BigProfile;
|
|
||||||
decisions: HerregistratieDecisions;
|
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 { Injectable, inject, resource } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
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';
|
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.
|
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||||
* ONE call returns registration + person + server-computed decisions. The data
|
* 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');
|
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({
|
return ok({
|
||||||
profile: { registration: reg, person },
|
profile: { registration, person: persoon },
|
||||||
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
|
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 { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.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 { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||||
import { submitChangeRequest } from '@registratie/application/submit-change-request';
|
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
|
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
|
||||||
@@ -17,7 +17,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-change-request-form',
|
selector: 'app-change-request-form',
|
||||||
imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
|
imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
|
||||||
template: `
|
template: `
|
||||||
@if (state().tag === 'Submitted') {
|
@if (state().tag === 'Submitted') {
|
||||||
<app-alert type="ok" i18n="@@changeRequest.success">
|
<app-alert type="ok" i18n="@@changeRequest.success">
|
||||||
@@ -50,7 +50,10 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ChangeRequestFormComponent {
|
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);
|
private store = createStore<State, Msg>(initial, reduce);
|
||||||
|
|
||||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
@@ -91,7 +94,7 @@ export class ChangeRequestFormComponent {
|
|||||||
private async runIfSubmitting() {
|
private async runIfSubmitting() {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Submitting') return;
|
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 });
|
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
||||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
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 @@
|
|||||||
/**
|
import { Role } from '@shared/domain/role';
|
||||||
* 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';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export function currentRole(): Role {
|
||||||
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
|
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user