feat(fp): WP-25 — server-rendered letter HTML preview
Adds LetterHtml.Render, a pure composer mirroring the FE letter canvas'
class vocabulary, behind two ExcludeFromDescription()'d endpoints
(GET /brief/preview, GET /admin/org-template/{subOrgId}/preview).
Auto-resolvable placeholders pull from seed/case data; unresolved
manual ones render as "[NOG IN TE VULLEN: label]". A sent brief
archives its composed HTML (BriefEntity.ArchivedHtml) so a later
org-template republish never changes it. FE gets a hand-written fetch
adapter (text/html, not JSON) and a "Voorbeeld" button that opens the
preview in a new tab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Letters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
@@ -26,6 +27,9 @@ public sealed class BriefEntity
|
||||
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
|
||||
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
|
||||
public int? SentOrgTemplateVersion { get; set; }
|
||||
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint
|
||||
/// serves this verbatim, so a later org-template republish never re-renders it.
|
||||
public string? ArchivedHtml { get; set; }
|
||||
|
||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||
}
|
||||
@@ -110,6 +114,10 @@ public static class BriefStore
|
||||
// Pin the org-template version the letter was sent with (WP-23): from here on
|
||||
// its appearance is frozen — republishing the template touches unsent briefs only.
|
||||
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
|
||||
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
|
||||
// serves this verbatim once sent, so a later republish never re-renders it.
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
|
||||
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260705101743_ArchivedHtml")]
|
||||
partial class ArchivedHtml
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AutoApprovable")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reden")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Referentie")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StepCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StepIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Submitted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Applications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AuditEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||
{
|
||||
b.Property<string>("BriefId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArchivedHtml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Beroep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DrafterId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Placeholders")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sections")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SentOrgTemplateVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SubOrgId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BriefId");
|
||||
|
||||
b.HasIndex("Owner")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Briefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("SubOrgId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("History")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PublishedVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("SubOrgId");
|
||||
|
||||
b.ToTable("OrgTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||
{
|
||||
b.Property<string>("DocumentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Linked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LocalId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WizardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DocumentId");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ArchivedHtml : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ArchivedHtml",
|
||||
table: "Briefs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ArchivedHtml",
|
||||
table: "Briefs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ namespace BigRegister.Api.Data.Migrations
|
||||
b.Property<string>("BriefId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArchivedHtml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Beroep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
|
||||
namespace BigRegister.Domain.Letters;
|
||||
|
||||
/// <summary>
|
||||
/// Server-rendered letter HTML (WP-25) — the archived, "what is sent" artifact.
|
||||
/// Mirrors the FE letter canvas' class vocabulary exactly (<c>public/letter.css</c>,
|
||||
/// the FE⇄BE contract; LetterHtmlTests' class-parity test is the fence against drift).
|
||||
///
|
||||
/// Unlike the canvas, placeholders resolve to real text rather than a live editor
|
||||
/// widget: an auto-resolvable key pulls from seed/case data (there is no per-brief
|
||||
/// resolved value stored anywhere else in the domain — see BriefEntity's own "does
|
||||
/// not interpret it" posture), an unresolved manual key renders literally as
|
||||
/// "[NOG IN TE VULLEN: label]" (PRD Brief v2 §8) — the preview works despite this,
|
||||
/// only send blocks on it (FE-authoritative linting).
|
||||
///
|
||||
/// ponytail: HTML today, a headless-Chromium PDF render slots in behind this same
|
||||
/// route if the POC ever needs real PDF bytes — see the preview endpoints.
|
||||
/// </summary>
|
||||
public static class LetterHtml
|
||||
{
|
||||
private static readonly string Css = File.ReadAllText(FindLetterCss());
|
||||
|
||||
public static string Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)
|
||||
{
|
||||
var defs = brief.Placeholders.ToDictionary(p => p.Key);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append("<!doctype html><html lang=\"nl\"><head><meta charset=\"utf-8\">");
|
||||
sb.Append("<title>").Append(Enc(brief.BriefId)).Append("</title>");
|
||||
sb.Append("<style>").Append(Css).Append(ExtraCss).Append("</style>");
|
||||
sb.Append("</head><body>");
|
||||
sb.Append("<div class=\"letter\" style=\"").Append(MarginStyle(template.Margins)).Append("\">");
|
||||
|
||||
// --- letterhead ---
|
||||
sb.Append("<div class=\"letter__letterhead\">");
|
||||
if (template.LogoDocumentId is { } logoId && DocumentStore.Get(logoId) is { } logo)
|
||||
{
|
||||
sb.Append("<img class=\"org-logo\" alt=\"\" src=\"data:").Append(logo.ContentType)
|
||||
.Append(";base64,").Append(Convert.ToBase64String(logo.Content)).Append("\">");
|
||||
}
|
||||
sb.Append("<p class=\"org-wordmark\">").Append(Enc(template.OrgName)).Append("</p>");
|
||||
sb.Append("<address class=\"return-address\">").Append(EncLines(template.ReturnAddress)).Append("</address>");
|
||||
sb.Append("<address class=\"address-window\">").Append(EncLines(RecipientPlaceholder)).Append("</address>");
|
||||
sb.Append("<dl class=\"reference\">");
|
||||
sb.Append("<div><dt>Ons kenmerk</dt><dd>").Append(Enc(brief.BriefId)).Append("</dd></div>");
|
||||
sb.Append("<div><dt>Datum</dt><dd>").Append(Enc(FormatDatumNl(at))).Append("</dd></div>");
|
||||
sb.Append("</dl></div>");
|
||||
|
||||
// --- body: the case-type template's sections ---
|
||||
sb.Append("<div class=\"letter__body\">");
|
||||
foreach (var section in brief.Sections)
|
||||
{
|
||||
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
|
||||
foreach (var block in section.Blocks)
|
||||
RenderParagraphs(sb, block.Content.Paragraphs, defs);
|
||||
sb.Append("</section>");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
|
||||
// --- signature ---
|
||||
sb.Append("<div class=\"letter__signature\">");
|
||||
sb.Append("<p>").Append(Enc(template.SignatureClosing)).Append("</p>");
|
||||
sb.Append("<p class=\"signature-name\">").Append(Enc(template.SignatureName)).Append("</p>");
|
||||
sb.Append("<p>").Append(Enc(template.SignatureRole)).Append("</p>");
|
||||
sb.Append("</div>");
|
||||
|
||||
// --- footer ---
|
||||
sb.Append("<div class=\"letter__footer\">");
|
||||
sb.Append("<div class=\"footer-contact\">").Append(EncLines(template.FooterContact)).Append("</div>");
|
||||
sb.Append("<div class=\"footer-legal\">").Append(Enc(template.FooterLegal)).Append("</div>");
|
||||
sb.Append("</div>");
|
||||
|
||||
if (watermark) sb.Append("<div class=\"preview-watermark\" aria-hidden=\"true\">VOORBEELD</div>");
|
||||
|
||||
sb.Append("</div></body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// Exposed so LetterHtmlTests can assert every `letter`-prefixed class this
|
||||
/// renderer emits also exists in the shared contract file — the fence against drift.
|
||||
public static string StyleSheet => Css;
|
||||
|
||||
// No recipient address is tracked anywhere in this POC's brief domain (BRP lookup
|
||||
// is out of scope here) — the canvas shows the same static placeholder text.
|
||||
private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)";
|
||||
|
||||
private static void RenderParagraphs(
|
||||
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
|
||||
{
|
||||
string? openList = null;
|
||||
foreach (var para in paragraphs)
|
||||
{
|
||||
if (para.List != openList)
|
||||
{
|
||||
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
|
||||
if (para.List is not null) sb.Append(para.List == "bullet" ? "<ul>" : "<ol>");
|
||||
openList = para.List;
|
||||
}
|
||||
sb.Append(openList is null ? "<p>" : "<li>");
|
||||
foreach (var node in para.Nodes) RenderNode(sb, node, defs);
|
||||
sb.Append(openList is null ? "</p>" : "</li>");
|
||||
}
|
||||
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
|
||||
}
|
||||
|
||||
private static void RenderNode(
|
||||
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
|
||||
{
|
||||
switch (node.Type)
|
||||
{
|
||||
case "text":
|
||||
sb.Append(Enc(node.Text ?? ""));
|
||||
break;
|
||||
case "lineBreak":
|
||||
sb.Append("<br>");
|
||||
break;
|
||||
case "placeholder":
|
||||
var key = node.Key ?? "";
|
||||
var def = defs.GetValueOrDefault(key);
|
||||
var label = def?.Label ?? key;
|
||||
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The only place a placeholder key gets a real value: seed/case data for the
|
||||
// single demo applicant (SeedData.Registration — no per-brief resolved value is
|
||||
// ever stored, see the class doc above). Falls back to the label itself for any
|
||||
// other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback.
|
||||
private static string ResolveAuto(string key, string label) => key switch
|
||||
{
|
||||
"naam_zorgverlener" => SeedData.Registration.Naam,
|
||||
"big_nummer" => SeedData.Registration.BigNummer,
|
||||
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")),
|
||||
_ => label,
|
||||
};
|
||||
|
||||
private static readonly CultureInfo Nl = CultureInfo.GetCultureInfo("nl-NL");
|
||||
private static string FormatDatumNl(string at) => DateTimeOffset.Parse(at).ToString("d MMMM yyyy", Nl);
|
||||
|
||||
private static string MarginStyle(MarginsDto m) =>
|
||||
$"--letter-margin-top:{m.TopMm}mm;--letter-margin-right:{m.RightMm}mm;" +
|
||||
$"--letter-margin-bottom:{m.BottomMm}mm;--letter-margin-left:{m.LeftMm}mm;";
|
||||
|
||||
private static string Enc(string s) => System.Net.WebUtility.HtmlEncode(s);
|
||||
private static string EncLines(string s) => Enc(s).Replace("\n", "<br>");
|
||||
|
||||
// Walks up from the running assembly's own directory (NOT the process cwd, which
|
||||
// varies by how `dotnet run`/docker/tests invoke it — see docs/backlog/WP-25) until
|
||||
// it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
|
||||
// api container's `/src` for exactly this walk to resolve there too.
|
||||
private static string FindLetterCss()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "public", "letter.css");
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
throw new FileNotFoundException(
|
||||
$"public/letter.css not found by walking up from {AppContext.BaseDirectory} " +
|
||||
"— check the docker bind mount or build output location.");
|
||||
}
|
||||
|
||||
// Backend-only concerns absent from the FE canvas (no live preview toggle for
|
||||
// either): kept out of the shared contract file, not "letter"-prefixed so the
|
||||
// class-parity test's scope doesn't need to widen for them.
|
||||
private const string ExtraCss = """
|
||||
.preview-watermark {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 72pt;
|
||||
font-weight: 700;
|
||||
color: rgb(200 30 30 / 0.18);
|
||||
transform: rotate(-30deg);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.org-logo {
|
||||
display: block;
|
||||
max-height: 18mm;
|
||||
margin-block-end: 4mm;
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -346,6 +346,31 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||
return Results.Content(archived, "text/html");
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||
// brief, so the appearance can be checked before publishing touches real letters.
|
||||
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||
if (view is null) return Results.NotFound();
|
||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||
}))
|
||||
.ExcludeFromDescription();
|
||||
|
||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
|
||||
Reference in New Issue
Block a user