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:
@@ -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);
|
||||
|
||||
176
backend/src/BigRegister.Api/Data/BriefStore.cs
Normal file
176
backend/src/BigRegister.Api/Data/BriefStore.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -640,6 +640,229 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SaveBriefRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief/submit": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "briefSubmit",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief/approve": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief/reject": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RejectBriefRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief/send": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -787,6 +1010,103 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"briefId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"beroep": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"templateId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"placeholders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlaceholderDefDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LetterSectionDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/BriefStatusDto"
|
||||
},
|
||||
"drafterId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"submittedBy": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"submittedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"approvedBy": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"approvedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rejectedBy": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rejectedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"comments": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sentAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"brief": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
},
|
||||
"availablePassages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LibraryPassageDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BrpAddressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1019,6 +1339,93 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LetterBlockDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"blockId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"content": {
|
||||
"$ref": "#/components/schemas/RichTextBlockDto"
|
||||
},
|
||||
"sourcePassageId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sourceVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"edited": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LetterSectionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sectionKey": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LetterBlockDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LibraryPassageDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passageId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sectionKey": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"content": {
|
||||
"$ref": "#/components/schemas/RichTextBlockDto"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"beroep": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ManualDiplomaPolicyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1039,6 +1446,19 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ParagraphDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RichTextNodeDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PersonDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1056,6 +1476,31 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PlaceholderDefDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"autoResolvable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fillable": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PolicyQuestionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1183,6 +1628,67 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RejectBriefRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"comments": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RichTextBlockDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paragraphs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ParagraphDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RichTextNodeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"marks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SaveBriefRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LetterSectionDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubmitApplicationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
137
backend/tests/BigRegister.Tests/BriefEndpointTests.cs
Normal file
137
backend/tests/BigRegister.Tests/BriefEndpointTests.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The brief is a single process-global demo entity, so each test resets it first
|
||||
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
||||
/// header: absent = drafter, "approver" = a different identity.
|
||||
/// </summary>
|
||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private static LetterBlockDto FreeText(string id) =>
|
||||
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
||||
|
||||
// Fill every REQUIRED section with a block so submit is allowed.
|
||||
private static SaveBriefRequest FilledFrom(BriefDto brief)
|
||||
{
|
||||
var i = 0;
|
||||
var sections = brief.Sections
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
|
||||
.ToList();
|
||||
return new SaveBriefRequest(sections);
|
||||
}
|
||||
|
||||
private async Task<BriefDto> Get()
|
||||
{
|
||||
BriefStore.Reset();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
return view!.Brief;
|
||||
}
|
||||
|
||||
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, path);
|
||||
if (role is not null) req.Headers.Add("X-Role", role);
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
||||
{
|
||||
var brief = await Get();
|
||||
Assert.Equal("draft", brief.Status.Tag);
|
||||
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||
Assert.All(brief.Sections, s => Assert.Empty(s.Blocks));
|
||||
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
||||
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_is_drafter_only()
|
||||
{
|
||||
var brief = await Get();
|
||||
var save = FilledFrom(brief);
|
||||
|
||||
var approver = Post("/api/v1/brief", role: "approver");
|
||||
approver.Method = HttpMethod.Put;
|
||||
approver.Content = JsonContent.Create(save);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
||||
{
|
||||
await Get();
|
||||
// Nothing filled yet → required sections empty → 409.
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
||||
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("submitted", submitted!.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
// drafter role approving own letter → 403
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
var rejected = await (await _client.SendAsync(
|
||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("rejected", rejected!.Status.Tag);
|
||||
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
||||
|
||||
// A drafter save on a rejected letter reopens it to draft.
|
||||
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("draft", reopened!.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_only_from_approved()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
// submitted (not approved) → send 409
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
||||
|
||||
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user