feat(brief): letter composition + two-person approval (teaching slice)

New `brief` context — a letter-composition feature with a drafter/approver
approval workflow, built as a teaching vertical slice on the repo's existing
FP + Elm + atomic-design patterns (see plan in ~/.claude/plans).

Domain (pure):
- Rich text as a serialisable value tree (placeholders are first-class nodes),
  moved to @shared/kernel/rich-text.ts so the shared editor can use it.
- lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored.
- brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot =
  deep value copy; derived diagnostics/editability. Full specs.

Backend (.NET stub):
- BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints,
  role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards,
  audit logging. Regenerated typed client via gen:api. +6 backend tests.

Seam:
- brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the
  parse boundary (+ spec).

UI (atomic):
- shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep
  contenteditable, DOM<->RichTextBlock round-trip tested).
- brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments,
  letter-section, letter-composer, letter-preview, brief.page + /brief route.
- Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link.

Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared).

Also included (same session):
- Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap.
- src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI).
- .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully
  broken — $localize unresolved — dev + build).

Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green,
Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:32:22 +02:00
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions

View File

@@ -239,10 +239,96 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---
api.MapGet("/brief", () =>
{
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
})
.Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/submit", (HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen.");
})
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/approve", (HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
LogBrief("approve", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
LogBrief("reject", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/send", () =>
{
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition.
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status409Conflict);
app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
static string Now() => DateTimeOffset.UtcNow.ToString("o");
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
// else (default) acts as the drafter.
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
{
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
}
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
{
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
};
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
action, r.outcome, r.entity?.Status.Tag ?? "-");
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store).