feat(brief): letter composition + two-person approval (teaching slice)
CI / backend (push) Failing after 22s
CI / frontend (push) Successful in 1m26s
CI / api-client-drift (push) Successful in 1m45s

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:
eho
2026-07-01 21:32:22 +02:00
co-authored by Claude Opus 4.8
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions
@@ -107,3 +107,45 @@ public sealed record SubmitApplicationRequest(
IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
// --- Brief (letter composition) contracts ---
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
// The backend treats the content as opaque (stores/returns it) — the FE owns the
// rich-text semantics and re-validates at its parse* boundary.
public sealed record RichTextNodeDto(string Type, string? Text = null, IReadOnlyList<string>? Marks = null, string? Key = null);
public sealed record ParagraphDto(IReadOnlyList<RichTextNodeDto> Nodes);
public sealed record RichTextBlockDto(IReadOnlyList<ParagraphDto> Paragraphs);
public sealed record PlaceholderDefDto(string Key, string Label, bool AutoResolvable, bool? Fillable = null, bool? Deprecated = null);
// LetterBlock union (passage | freeText), flattened: passage-only fields are nullable.
public sealed record LetterBlockDto(
string Type, string BlockId, RichTextBlockDto Content,
string? SourcePassageId = null, int? SourceVersion = null, bool? Edited = null);
public sealed record LetterSectionDto(string SectionKey, string Title, bool Required, IReadOnlyList<LetterBlockDto> Blocks);
// BriefStatus union, flattened by Tag (draft | submitted | approved | rejected | sent).
public sealed record BriefStatusDto(
string Tag,
string? SubmittedBy = null, string? SubmittedAt = null,
string? ApprovedBy = null, string? ApprovedAt = null,
string? RejectedBy = null, string? RejectedAt = null, string? Comments = null,
string? SentAt = null);
public sealed record LibraryPassageDto(
string PassageId, string Scope, string SectionKey, string Label,
RichTextBlockDto Content, int Version, string? Beroep = null);
public sealed record BriefDto(
string BriefId, string Beroep, string TemplateId,
IReadOnlyList<PlaceholderDefDto> Placeholders,
IReadOnlyList<LetterSectionDto> Sections,
BriefStatusDto Status, string DrafterId);
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages);
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments);
@@ -0,0 +1,176 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
/// its guards live here (the server is authoritative for transitions); the FE mirrors
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
/// stub does not interpret it (no server-side placeholder linting in this slice).
/// </summary>
public sealed class BriefEntity
{
public required string BriefId { get; init; }
public required string Owner { get; init; }
public required string Beroep { get; init; }
public required string TemplateId { get; init; }
public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft");
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
}
public static class BriefStore
{
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver";
public enum Outcome { Ok, Forbidden, Conflict }
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner)
{
lock (_gate)
{
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
}
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
return (Outcome.Ok, e);
}
}
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
return (Outcome.Ok, e);
}
}
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Send(string owner, string at)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
return (Outcome.Ok, e);
}
}
/// Test seam: clear the demo brief between tests.
public static void Reset()
{
lock (_gate) _byOwner.Clear();
}
// Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter).
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e);
return (Outcome.Ok, e);
}
}
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
}
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
public static class BriefSeed
{
public const string TemplateId = "besluit-arts";
public const string Beroep = "arts";
private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
// The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
new PlaceholderDefDto("reden_besluit", "Reden besluit", false),
new PlaceholderDefDto("oud_kenmerk", "Oud kenmerk", true, Deprecated: true),
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
};
public static BriefEntity NewBrief(string owner) => new()
{
BriefId = Guid.NewGuid().ToString(),
Owner = owner,
Beroep = Beroep,
TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders,
Sections = new()
{
new("aanhef", "Aanhef", true, new List<LetterBlockDto>()),
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>()),
new("slot", "Slot", false, new List<LetterBlockDto>()),
},
Status = new BriefStatusDto("draft"),
};
/// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{
var all = new List<LibraryPassageDto>
{
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
new("p-kern-1", "global", "kern", "Beoordeling ontvangen",
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1),
new("p-kern-arts", "beroep", "kern", "Toelichting arts",
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts"),
new("p-slot-1", "global", "slot", "Standaard slot",
Block(T("Met vriendelijke groet,")), 1),
// These reference a deprecated / not-fillable field so the linter's
// warning + error states are visible in the demo once inserted.
new("p-kern-oud", "global", "kern", "Verwijzing oud kenmerk (verouderd)",
Block(T("Zie kenmerk "), P("oud_kenmerk"), T(".")), 1),
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
};
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
}
}
+86
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).