feat(WP-67): merge behandelportal into this repo as a monorepo
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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Parse, don't validate" />
|
||||
|
||||
# Parse, don't validate
|
||||
|
||||
The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown`
|
||||
until something checks it — casting it away with `as` doesn't check anything, it just tells the
|
||||
compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a
|
||||
hand-written `parse*` function that returns a `Result<string, T>` (`src/app/shared/kernel/fp.ts`).
|
||||
Once you hold the parsed value, you never re-check it — the type _is_ the proof.
|
||||
|
||||
## Two places this shows up
|
||||
|
||||
**Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered
|
||||
field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type.
|
||||
|
||||
**Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one
|
||||
enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the
|
||||
rest of the app trusts.
|
||||
|
||||
```ts
|
||||
parsePostcode(raw) // Result<string, Postcode>
|
||||
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||
|> map(toDomain) // only runs on success
|
||||
```
|
||||
|
||||
## The failure mode this closes: the silent `as` cast
|
||||
|
||||
An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag
|
||||
just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated
|
||||
parse turns that into an explicit `Failure` at the boundary, right where the untrusted data
|
||||
enters.
|
||||
|
||||
### Before/after: `big-register.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — the wire's `type` string is trusted outright
|
||||
function toAantekening(n: AantekeningDto): Aantekening {
|
||||
return {
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — an unrecognized type is a Result you can spec, not a silently-wrong tag
|
||||
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
|
||||
|
||||
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
|
||||
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
|
||||
return err(`aantekening: unknown type ${n.type}`);
|
||||
return ok({
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The resource loader throws on `Failure`, which Angular's `resource()` turns into its error
|
||||
state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing.
|
||||
|
||||
### Before/after: `brief.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — `dto.scope` is checked, then re-cast anyway
|
||||
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
|
||||
return err('passage: bad shape');
|
||||
// … scope: dto.scope as PassageScope
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — split the guard so TS narrows `scope` on its own; no cast needed
|
||||
if (dto.scope !== 'global' && dto.scope !== 'beroep')
|
||||
return err(`passage: unknown scope ${dto.scope}`);
|
||||
// … scope: dto.scope // already narrowed to PassageScope
|
||||
```
|
||||
|
||||
Splitting a compound `if` into two single-condition guards is often enough to make the cast
|
||||
disappear entirely — the compiler was already able to prove the narrowing, the `||` was just
|
||||
hiding it.
|
||||
|
||||
### Before/after: `intake-policy.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — the resource exposes the raw DTO; consumers reach into it with `?.`
|
||||
policyResource() {
|
||||
return resource({ loader: () => this.client.policy() });
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — a domain-side type + a validated parse; the resource never surfaces raw wire shape
|
||||
export interface IntakePolicy {
|
||||
readonly scholingThreshold: number;
|
||||
}
|
||||
|
||||
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
|
||||
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
|
||||
const dto = json as { scholingThreshold?: unknown };
|
||||
if (typeof dto.scholingThreshold !== 'number')
|
||||
return err('intake-policy: missing scholingThreshold');
|
||||
return ok({ scholingThreshold: dto.scholingThreshold });
|
||||
}
|
||||
```
|
||||
|
||||
## The sanctioned exception
|
||||
|
||||
Narrowing `unknown` to `Partial<Dto>` so you can _start_ checking fields is fine — that's not a
|
||||
trust decision, it's just giving the compiler a shape to probe (`const dto = json as
|
||||
Partial<DashboardViewDto>`, see `dashboard-view.adapter.ts`). What's never fine is casting a
|
||||
field to its final domain type without having checked it first.
|
||||
|
||||
## Spec every parser like a decision table
|
||||
|
||||
Each parser gets a spec covering: a valid shape, a missing required field, and — for
|
||||
tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`,
|
||||
`intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.
|
||||
Reference in New Issue
Block a user