Compare commits
2 Commits
c07a33ee3e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c837f281 | |||
| 1bb9383344 |
@@ -1,5 +1,6 @@
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
using BigRegister.Domain.Letters;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
@@ -26,6 +27,9 @@ public sealed class BriefEntity
|
|||||||
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
|
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
|
||||||
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
|
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
|
||||||
public int? SentOrgTemplateVersion { get; set; }
|
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);
|
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
|
// 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.
|
// its appearance is frozen — republishing the template touches unsent briefs only.
|
||||||
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
|
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();
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
|
|||||||
226
backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs
generated
Normal file
226
backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs
generated
Normal file
@@ -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")
|
b.Property<string>("BriefId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ArchivedHtml")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Beroep")
|
b.Property<string>("Beroep")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|||||||
191
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
Normal file
191
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
Normal file
@@ -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>()
|
.Produces<BriefViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.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) =>
|
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||||
|
|||||||
@@ -23,4 +23,10 @@
|
|||||||
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="LetterHtml.golden.html">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
215
backend/tests/BigRegister.Tests/LetterHtml.golden.html
Normal file
215
backend/tests/BigRegister.Tests/LetterHtml.golden.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<!doctype html><html lang="nl"><head><meta charset="utf-8"><title>golden-brief-1</title><style>/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
|
||||||
|
*
|
||||||
|
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
|
||||||
|
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
|
||||||
|
* inlines this same file. Its class-parity test is the fence against drift.
|
||||||
|
*
|
||||||
|
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
|
||||||
|
* .letter__signature, .letter__footer, .letter__page-break.
|
||||||
|
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
|
||||||
|
* the defaults below match the seed template.
|
||||||
|
*
|
||||||
|
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
|
||||||
|
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
|
||||||
|
* (A4, return address above the envelope window, reference block, footer rule).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
--letter-margin-top: 25mm;
|
||||||
|
--letter-margin-right: 25mm;
|
||||||
|
--letter-margin-bottom: 25mm;
|
||||||
|
--letter-margin-left: 25mm;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 210mm; /* A4 */
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
|
||||||
|
var(--letter-margin-left);
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
|
||||||
|
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter ul,
|
||||||
|
.letter ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-inline-start: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
|
||||||
|
|
||||||
|
.letter__letterhead {
|
||||||
|
margin-block-end: 12mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .org-wordmark {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .return-address {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: #555;
|
||||||
|
margin-block-end: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Envelope-window position (~C5 venstercouvert): recipient block. */
|
||||||
|
.letter__letterhead .address-window {
|
||||||
|
min-height: 22mm;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-block-end: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference {
|
||||||
|
display: flex;
|
||||||
|
gap: 10mm;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dt {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Body: the case-type template's sections --- */
|
||||||
|
|
||||||
|
.letter__body {
|
||||||
|
flex: 1;
|
||||||
|
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
|
||||||
|
in the gaps but never draws THROUGH opaque content (editors, pickers). */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body h3 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body section {
|
||||||
|
margin-block-end: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Signature --- */
|
||||||
|
|
||||||
|
.letter__signature {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature .signature-name {
|
||||||
|
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Footer: contact + legal, above a rule --- */
|
||||||
|
|
||||||
|
.letter__footer {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
padding-block-start: 3mm;
|
||||||
|
border-block-start: 0.5pt solid #999;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-contact {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-legal {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
|
||||||
|
Positioned per A4-interval by the canvas; the print preview is authoritative. */
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
border-block-start: 1px dashed #b36200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break > span {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 2mm;
|
||||||
|
inset-block-start: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
/* Above .letter__body: the honesty caption stays legible even over content. */
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #b36200;
|
||||||
|
background: #fff;
|
||||||
|
padding-inline: 1mm;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Print: real pages, no indicator chrome --- */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.letter {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}</style></head><body><div class="letter" style="--letter-margin-top:25mm;--letter-margin-right:20mm;--letter-margin-bottom:20mm;--letter-margin-left:25mm;"><div class="letter__letterhead"><p class="org-wordmark">BIG-register</p><address class="return-address">Retouradres: Postbus 00000, 2500 AA Den Haag</address><address class="address-window">Adres van de geadresseerde<br>(wordt ingevuld bij verzending)</address><dl class="reference"><div><dt>Ons kenmerk</dt><dd>golden-brief-1</dd></div><div><dt>Datum</dt><dd>5 juli 2026</dd></div></dl></div><div class="letter__body"><section><h3>Aanhef</h3><p>Geachte heer/mevrouw Dr. A. (Anna) de Vries,</p></section><section><h3>Kern van het besluit</h3><p>Op </p><ul><li>Eerste punt: [NOG IN TE VULLEN: Reden besluit]</li><li>Tweede punt</li></ul></section><section><h3>Slot</h3><p>Met vriendelijke groet,</p></section></div><div class="letter__signature"><p>Met vriendelijke groet,</p><p class="signature-name">A. de Vries</p><p>Hoofd Registratie, BIG-register</p></div><div class="letter__footer"><div class="footer-contact">BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example</div><div class="footer-legal">Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.</div></div><div class="preview-watermark" aria-hidden="true">VOORBEELD</div></div></body></html>
|
||||||
102
backend/tests/BigRegister.Tests/LetterHtmlTests.cs
Normal file
102
backend/tests/BigRegister.Tests/LetterHtmlTests.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Letters;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP-25's fence against drift between the backend renderer and the FE letter
|
||||||
|
/// canvas: a golden-file snapshot of a fixed brief + template, and a class-parity
|
||||||
|
/// check that every `letter`-prefixed class the renderer emits exists in the
|
||||||
|
/// shared `public/letter.css` contract. Neither test launches a browser.
|
||||||
|
/// </summary>
|
||||||
|
public class LetterHtmlTests
|
||||||
|
{
|
||||||
|
private static BriefEntity FixtureBrief() => new()
|
||||||
|
{
|
||||||
|
BriefId = "golden-brief-1",
|
||||||
|
Owner = "golden",
|
||||||
|
Beroep = "arts",
|
||||||
|
TemplateId = "besluit-arts",
|
||||||
|
DrafterId = BriefStore.DrafterId,
|
||||||
|
Placeholders = new[]
|
||||||
|
{
|
||||||
|
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
|
||||||
|
new PlaceholderDefDto("datum", "Datum", true),
|
||||||
|
new PlaceholderDefDto("reden_besluit", "Reden besluit", false),
|
||||||
|
},
|
||||||
|
Sections = new()
|
||||||
|
{
|
||||||
|
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "aanhef-1", new RichTextBlockDto(new[]
|
||||||
|
{
|
||||||
|
new ParagraphDto(new[]
|
||||||
|
{
|
||||||
|
new RichTextNodeDto("text", Text: "Geachte heer/mevrouw "),
|
||||||
|
new RichTextNodeDto("placeholder", Key: "naam_zorgverlener"),
|
||||||
|
new RichTextNodeDto("text", Text: ","),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}, Locked: true),
|
||||||
|
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "kern-1", new RichTextBlockDto(new[]
|
||||||
|
{
|
||||||
|
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Op ") }),
|
||||||
|
new ParagraphDto(new RichTextNodeDto[]
|
||||||
|
{
|
||||||
|
new("text", Text: "Eerste punt: "),
|
||||||
|
new("placeholder", Key: "reden_besluit"),
|
||||||
|
}, List: "bullet"),
|
||||||
|
new ParagraphDto(new RichTextNodeDto[]
|
||||||
|
{
|
||||||
|
new("text", Text: "Tweede punt"),
|
||||||
|
}, List: "bullet"),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
new("slot", "Slot", false, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "slot-1", new RichTextBlockDto(new[]
|
||||||
|
{
|
||||||
|
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Met vriendelijke groet,") }),
|
||||||
|
})),
|
||||||
|
}, Locked: true),
|
||||||
|
},
|
||||||
|
Status = new BriefStatusDto("draft"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly OrgTemplateDto Template = new(
|
||||||
|
"cibg-registers", "BIG-register",
|
||||||
|
"Retouradres: Postbus 00000, 2500 AA Den Haag",
|
||||||
|
LogoDocumentId: null,
|
||||||
|
"BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example",
|
||||||
|
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
|
||||||
|
"A. de Vries", "Hoofd Registratie, BIG-register", "Met vriendelijke groet,",
|
||||||
|
new MarginsDto(25, 20, 20, 25), Version: 1);
|
||||||
|
|
||||||
|
private const string At = "2026-07-05T12:00:00.0000000+00:00";
|
||||||
|
|
||||||
|
private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_matches_the_golden_file()
|
||||||
|
{
|
||||||
|
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
|
||||||
|
var golden = File.ReadAllText(GoldenPath);
|
||||||
|
Assert.Equal(golden, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_letter_prefixed_class_exists_in_letter_css()
|
||||||
|
{
|
||||||
|
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
|
||||||
|
var classes = Regex.Matches(html, "class=\"([^\"]+)\"")
|
||||||
|
.SelectMany(m => m.Groups[1].Value.Split(' '))
|
||||||
|
.Where(c => c.StartsWith("letter"))
|
||||||
|
.Distinct();
|
||||||
|
Assert.NotEmpty(classes);
|
||||||
|
Assert.All(classes, c => Assert.Contains($".{c}", LetterHtml.StyleSheet));
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
Normal file
96
backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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>
|
||||||
|
/// WP-25: the two HTML preview endpoints. Both are excluded from the OpenAPI doc
|
||||||
|
/// (see the drift check in the API-client generation step) — these tests hit them
|
||||||
|
/// as plain HTTP, the same way the hand-written FE fetch does.
|
||||||
|
/// </summary>
|
||||||
|
public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private const string Registers = OrgTemplateSeed.Registers;
|
||||||
|
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") }) }));
|
||||||
|
|
||||||
|
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 static void ResetStores()
|
||||||
|
{
|
||||||
|
BriefStore.Reset();
|
||||||
|
OrgTemplateStore.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null) =>
|
||||||
|
role is null ? new HttpRequestMessage(method, path) : new HttpRequestMessage(method, path) { Headers = { { "X-Role", role } } };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
|
||||||
|
|
||||||
|
var res = await _client.GetAsync("/api/v1/brief/preview");
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal("text/html", res.Content.Headers.ContentType?.MediaType);
|
||||||
|
var html = await res.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("<div class=\"preview-watermark\"", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"));
|
||||||
|
|
||||||
|
var sentHtml = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
|
||||||
|
Assert.DoesNotContain("<div class=\"preview-watermark\"", sentHtml);
|
||||||
|
Assert.Contains("BIG-register", sentHtml);
|
||||||
|
|
||||||
|
// Republish the org template under a new name.
|
||||||
|
var adminView = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}", role: "admin"));
|
||||||
|
var draft = (await adminView.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!.Draft;
|
||||||
|
await _client.SendAsync(new HttpRequestMessage(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}")
|
||||||
|
{
|
||||||
|
Headers = { { "X-Role", "admin" } },
|
||||||
|
Content = JsonContent.Create(new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })),
|
||||||
|
});
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||||
|
|
||||||
|
// The sent brief's preview is unchanged — still the archived rendering.
|
||||||
|
var afterRepublish = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
|
||||||
|
Assert.Equal(sentHtml, afterRepublish);
|
||||||
|
Assert.DoesNotContain("Hertitelde organisatie", afterRepublish);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Proefbrief_is_admin_only_and_renders_the_draft_template()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden,
|
||||||
|
(await _client.GetAsync($"/api/v1/admin/org-template/{Registers}/preview")).StatusCode);
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}/preview", role: "admin"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var html = await res.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("BIG-register", html);
|
||||||
|
Assert.Contains("<div class=\"preview-watermark\"", html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ services:
|
|||||||
- ./backend:/src:z
|
- ./backend:/src:z
|
||||||
- api-bin:/src/src/BigRegister.Api/bin
|
- api-bin:/src/src/BigRegister.Api/bin
|
||||||
- api-obj:/src/src/BigRegister.Api/obj
|
- api-obj:/src/src/BigRegister.Api/obj
|
||||||
|
# WP-25: LetterHtml.Render inlines public/letter.css (the FE⇄BE letter
|
||||||
|
# contract, repo-root sibling of backend/) — mounted here so the same
|
||||||
|
# walk-up-from-the-assembly lookup that works for `dotnet run`/tests also
|
||||||
|
# resolves inside this container, whose bind mount otherwise only sees backend/.
|
||||||
|
- ./public:/src/public:z
|
||||||
ports:
|
ports:
|
||||||
- '5000:5000'
|
- '5000:5000'
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||||
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||||
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
|
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
|
||||||
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | todo |
|
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
||||||
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
|
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
|
||||||
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
|
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
|
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -63,13 +63,13 @@ same composition is archived with the brief, making sent letters immutable.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Preview opens the composed print-ready letter in a new tab; browser print
|
- [x] Preview opens the composed print-ready letter in a new tab; browser print
|
||||||
shows correct margins via `@page`.
|
shows correct margins via `@page`.
|
||||||
- [ ] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
|
- [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
|
||||||
despite lint errors (only send blocks).
|
despite lint errors (only send blocks).
|
||||||
- [ ] A sent brief serves its archived HTML unchanged after an org-template republish.
|
- [x] A sent brief serves its archived HTML unchanged after an org-template republish.
|
||||||
- [ ] Golden + parity tests green without any browser installed.
|
- [x] Golden + parity tests green without any browser installed.
|
||||||
- [ ] `swagger.json` unchanged by the two endpoints (drift check green).
|
- [x] `swagger.json` unchanged by the two endpoints (drift check green).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-26 — Admin org-template editor
|
# WP-26 — Admin org-template editor
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -58,17 +58,45 @@ content a read-only sample). PRD Brief v2 §5, §7h.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `?role=admin` can switch sub-orgs, edit all template fields in place on the
|
- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the
|
||||||
canvas, and see the canvas update live.
|
canvas, and see the canvas update live.
|
||||||
- [ ] Draft saves are debounced; publish asks for confirmation showing the impact
|
- [x] Draft saves are debounced; publish asks for confirmation showing the impact
|
||||||
count; after publish the drafter's canvas (WP-24) reflects it on reload.
|
count; after publish the drafter's canvas (WP-24) reflects it on reload.
|
||||||
- [ ] Version history lists published versions (who is faked, when is real);
|
- [x] Version history lists published versions (who is faked, when is real);
|
||||||
rollback copies an old version into the draft.
|
rollback copies an old version into the draft.
|
||||||
- [ ] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
|
- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
|
||||||
- [ ] Logo upload validates type/size client-side (existing `rejectReason`) and
|
- [x] Logo upload validates type/size client-side (existing `rejectReason`) and
|
||||||
renders on the canvas after upload.
|
renders on the canvas after upload.
|
||||||
- [ ] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
|
- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
|
||||||
- [ ] Full GREEN.
|
- [x] Full GREEN.
|
||||||
|
|
||||||
|
## Deviations / notes (as built)
|
||||||
|
|
||||||
|
- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*,
|
||||||
|
#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here
|
||||||
|
to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every
|
||||||
|
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
|
||||||
|
`passage-picker` `var(--rhc-color-wit, #fff)` hit.
|
||||||
|
- **Canvas edit-in-place**: `editableRegions='template'` now renders the seven
|
||||||
|
org-identity text fields as inline `<input>`/`<textarea>` controls (aria-labelled)
|
||||||
|
and shows the logo `<img>`; the letter body stays a read-only sample
|
||||||
|
(`SAMPLE_LETTER_BRIEF`). `logoUrl` input added to the canvas and wired through the
|
||||||
|
composer too, so a published logo shows to the drafter (AC2).
|
||||||
|
- **Load-effect loop (caught in the live walk)**: the page's initial-load effect must
|
||||||
|
NOT read the store model — `load()` dispatches `Loading` (a fresh object), which
|
||||||
|
would retrigger a model-reading effect into a runaway loop that saturated the page.
|
||||||
|
Gated on `canEdit()` + a plain `loadRequested` flag instead.
|
||||||
|
- **`AccessStore.ready`** added (tiny): a page-level capability gate needs to tell
|
||||||
|
"still loading `/me`" from "denied" so an admin doesn't flash the denial alert.
|
||||||
|
- **`uploadContentUrl`** pure helper extracted from `UploadAdapter.contentUrl` so
|
||||||
|
`BriefStore` can build a logo `src` without pulling `ApiClient` into its DI graph
|
||||||
|
(kept its spec green).
|
||||||
|
- **Role caching caveat**: `/me` loads once. Open the app with `?role=admin` from the
|
||||||
|
first navigation that touches the editor (the dev role stub, like `?scenario=`);
|
||||||
|
switching role mid-session won't refetch capabilities (out of scope, POC).
|
||||||
|
- **Dirty race**: `DraftSaved` carries the saved draft and clears `dirty` only if it's
|
||||||
|
reference-equal to the current draft, so an edit landing during a save round-trip
|
||||||
|
keeps its pending save.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
4197
documentation.json
4197
documentation.json
File diff suppressed because one or more lines are too long
@@ -51,6 +51,12 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'brief/huisstijl',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'concepts',
|
path: 'concepts',
|
||||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
import { BriefStore } from './brief.store';
|
import { BriefStore } from './brief.store';
|
||||||
|
|
||||||
const decisions: BriefDecisions = {
|
const decisions: BriefDecisions = {
|
||||||
@@ -103,3 +104,43 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
expect(store.lastError()).toBeNull();
|
expect(store.lastError()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('BriefStore.previewLetter', () => {
|
||||||
|
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||||
|
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('opens the composed letter in a new tab on success', async () => {
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
value: blob,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.previewLetter();
|
||||||
|
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
||||||
|
expect(store.lastError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the error without opening a tab on failure', async () => {
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: 'De voorvertoning kon niet worden geopend.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.previewLetter();
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
|
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||||
|
|
||||||
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
||||||
instead of a busy boolean + a nullable error sitting side by side. */
|
instead of a busy boolean + a nullable error sitting side by side. */
|
||||||
@@ -35,6 +37,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BriefStore {
|
export class BriefStore {
|
||||||
private adapter = inject(BriefAdapter);
|
private adapter = inject(BriefAdapter);
|
||||||
|
private previewAdapter = inject(LetterPreviewAdapter);
|
||||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||||
|
|
||||||
readonly model = this.store.model;
|
readonly model = this.store.model;
|
||||||
@@ -54,6 +57,12 @@ export class BriefStore {
|
|||||||
stays untouched by design). Set from every server view that carries it. */
|
stays untouched by design). Set from every server view that carries it. */
|
||||||
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||||
|
|
||||||
|
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
||||||
|
readonly logoUrl = computed<string | null>(() => {
|
||||||
|
const id = this.orgTemplate()?.logoDocumentId;
|
||||||
|
return id ? uploadContentUrl(id) : null;
|
||||||
|
});
|
||||||
|
|
||||||
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||||
purely a projection of its loading/failed tags onto the shared async seam. */
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||||
@@ -147,6 +156,20 @@ export class BriefStore {
|
|||||||
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
||||||
send = () => this.transition(() => this.adapter.send());
|
send = () => this.transition(() => this.adapter.send());
|
||||||
|
|
||||||
|
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||||
|
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||||
|
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||||
|
async previewLetter() {
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
const r = await this.previewAdapter.preview();
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
window.open(URL.createObjectURL(r.value), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||||
// the returned status through the pure reducer's guarded transition.
|
// the returned status through the pure reducer's guarded transition.
|
||||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||||
|
|||||||
271
src/app/brief/application/org-template.store.ts
Normal file
271
src/app/brief/application/org-template.store.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||||
|
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
||||||
|
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
||||||
|
import {
|
||||||
|
MARGIN_MAX_MM,
|
||||||
|
MARGIN_MIN_MM,
|
||||||
|
OrgTemplate,
|
||||||
|
SubOrgSummary,
|
||||||
|
} from '@brief/domain/org-template';
|
||||||
|
import {
|
||||||
|
OrgTemplateMsg,
|
||||||
|
OrgTemplateState,
|
||||||
|
initial,
|
||||||
|
reduce,
|
||||||
|
} from '@brief/domain/org-template.machine';
|
||||||
|
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||||
|
|
||||||
|
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
|
||||||
|
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||||
|
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
||||||
|
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||||
|
|
||||||
|
const LOGO_CATEGORY = 'org-logo';
|
||||||
|
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
|
||||||
|
* editable draft; commands here do the debounced save, publish (impact-confirm),
|
||||||
|
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
|
||||||
|
* logo upload reuses the shared upload transport; its completion mutates the draft
|
||||||
|
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class OrgTemplateStore {
|
||||||
|
private adapter = inject(OrgTemplateAdapter);
|
||||||
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
|
private shell = inject(UploadShellService);
|
||||||
|
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||||
|
|
||||||
|
readonly model = this.store.model;
|
||||||
|
|
||||||
|
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||||
|
readonly selectedSubOrgId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||||
|
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||||
|
readonly lastError = computed(() => {
|
||||||
|
const s = this.actionState();
|
||||||
|
return s.tag === 'Failed' ? s.error : null;
|
||||||
|
});
|
||||||
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
|
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||||
|
readonly pendingPublish = signal(false);
|
||||||
|
|
||||||
|
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
|
||||||
|
const s = this.model();
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'loading':
|
||||||
|
return { tag: 'Loading' };
|
||||||
|
case 'failed':
|
||||||
|
return { tag: 'Failure', error: new Error(s.reason) };
|
||||||
|
case 'loaded':
|
||||||
|
return { tag: 'Success', value: s };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'loaded' ? s : null;
|
||||||
|
});
|
||||||
|
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||||
|
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
||||||
|
readonly history = computed(() => this.loaded()?.history ?? []);
|
||||||
|
readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);
|
||||||
|
readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);
|
||||||
|
readonly logoUrl = computed<string | null>(() => {
|
||||||
|
const id = this.draft()?.logoDocumentId;
|
||||||
|
return id ? this.uploadAdapter.contentUrl(id) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
||||||
|
the server re-validates and stays the authority — publish is gated on this. */
|
||||||
|
readonly draftValid = computed(() => {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d) return false;
|
||||||
|
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(
|
||||||
|
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,
|
||||||
|
);
|
||||||
|
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).
|
||||||
|
private files = new Map<string, File>();
|
||||||
|
private categoriesRes = this.uploadAdapter.categoriesResource('org-template');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Feed the logo category into the machine's upload sub-state once loaded. Tracks
|
||||||
|
// `model()` so it re-fires after a sub-org switch reseeds an empty upload state;
|
||||||
|
// the length guard makes it idempotent (no dispatch loop).
|
||||||
|
effect(() => {
|
||||||
|
const s = this.model();
|
||||||
|
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
||||||
|
const status = this.categoriesRes.status();
|
||||||
|
if (status === 'resolved' || status === 'local')
|
||||||
|
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.store.dispatch({ tag: 'Loading' });
|
||||||
|
const list = await this.adapter.list();
|
||||||
|
if (!list.ok) {
|
||||||
|
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.subOrgs.set(list.value);
|
||||||
|
const first = list.value[0];
|
||||||
|
if (!first) {
|
||||||
|
this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.selectSubOrg(first.subOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectSubOrg(subOrgId: string) {
|
||||||
|
this.selectedSubOrgId.set(subOrgId);
|
||||||
|
this.saveState.set({ tag: 'Idle' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
this.store.dispatch({ tag: 'Loading' });
|
||||||
|
const r = await this.adapter.load(subOrgId);
|
||||||
|
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
||||||
|
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
|
||||||
|
edit(msg: OrgTemplateMsg) {
|
||||||
|
this.store.dispatch(msg);
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private scheduleSave() {
|
||||||
|
if (this.loaded() === null) return;
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
|
||||||
|
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
||||||
|
}
|
||||||
|
private async flushSave() {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s || !s.dirty) return;
|
||||||
|
const { subOrgId, draft } = s;
|
||||||
|
this.saveState.set({ tag: 'Saving' });
|
||||||
|
const r = await this.adapter.save(subOrgId, draft);
|
||||||
|
if (r.ok) {
|
||||||
|
this.saveState.set({ tag: 'Saved' });
|
||||||
|
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||||
|
} else {
|
||||||
|
this.saveState.set({ tag: 'Error' });
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- publish (impact-confirm) / rollback / proefbrief ---
|
||||||
|
|
||||||
|
requestPublish() {
|
||||||
|
this.pendingPublish.set(true);
|
||||||
|
}
|
||||||
|
cancelPublish() {
|
||||||
|
this.pendingPublish.set(false);
|
||||||
|
}
|
||||||
|
async confirmPublish() {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s) return;
|
||||||
|
this.pendingPublish.set(false);
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||||
|
const r = await this.adapter.publish(s.subOrgId);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollback(version: number) {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s) return;
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||||
|
}
|
||||||
|
|
||||||
|
async proefbrief() {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s) return;
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
await this.flushSave(); // the proefbrief renders the server's draft
|
||||||
|
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
window.open(URL.createObjectURL(r.value), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||||
|
|
||||||
|
onLogoSelected(files: File[]) {
|
||||||
|
const s = this.loaded();
|
||||||
|
const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);
|
||||||
|
const file = files[0];
|
||||||
|
if (!s || !cat || !file) return;
|
||||||
|
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||||
|
if (reason) {
|
||||||
|
this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localId = crypto.randomUUID();
|
||||||
|
this.files.set(localId, file);
|
||||||
|
this.dispatchUpload({
|
||||||
|
type: 'FileSelected',
|
||||||
|
categoryId: cat.categoryId,
|
||||||
|
localId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSizeMb: file.size / 1e6,
|
||||||
|
});
|
||||||
|
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>
|
||||||
|
this.onUploadMsg(m),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogoRemoved(localId: string) {
|
||||||
|
this.shell.cancel([localId]);
|
||||||
|
this.files.delete(localId);
|
||||||
|
this.onUploadMsg({ type: 'UploadRemoved', localId });
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogoRetry(localId: string) {
|
||||||
|
const file = this.files.get(localId);
|
||||||
|
const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);
|
||||||
|
if (!file || !up) return;
|
||||||
|
this.dispatchUpload({ type: 'UploadRetried', localId });
|
||||||
|
this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>
|
||||||
|
this.onUploadMsg(m),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchUpload(msg: UploadMsg) {
|
||||||
|
this.store.dispatch({ tag: 'Upload', msg });
|
||||||
|
}
|
||||||
|
/** Upload effects arriving from the transport: a finished/removed logo edits the
|
||||||
|
draft (in the reducer) and needs persisting. */
|
||||||
|
private onUploadMsg(msg: UploadMsg) {
|
||||||
|
this.dispatchUpload(msg);
|
||||||
|
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/app/brief/domain/org-template.machine.spec.ts
Normal file
135
src/app/brief/domain/org-template.machine.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||||
|
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||||
|
import { DocumentCategory } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
|
const template: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG',
|
||||||
|
returnAddress: 'Postbus 1\n2500 AA Den Haag',
|
||||||
|
footerContact: 'info@cibg.nl',
|
||||||
|
footerLegal: 'CIBG is onderdeel van VWS',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
|
||||||
|
draft: template,
|
||||||
|
publishedVersion: 3,
|
||||||
|
history: [],
|
||||||
|
unsentBriefs: 2,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||||
|
|
||||||
|
const logoCategory: DocumentCategory = {
|
||||||
|
categoryId: 'org-logo',
|
||||||
|
label: 'Logo',
|
||||||
|
description: '',
|
||||||
|
required: false,
|
||||||
|
acceptedTypes: ['image/png'],
|
||||||
|
maxSizeMb: 2,
|
||||||
|
multiple: false,
|
||||||
|
allowPostDelivery: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('org-template.machine', () => {
|
||||||
|
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||||
|
const s = loaded();
|
||||||
|
expect(s.tag).toBe('loaded');
|
||||||
|
if (s.tag !== 'loaded') return;
|
||||||
|
expect(s.draft.orgName).toBe('CIBG');
|
||||||
|
expect(s.subOrgId).toBe('cibg-registers');
|
||||||
|
expect(s.unsentBriefs).toBe(2);
|
||||||
|
expect(s.dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LoadFailed carries the reason', () => {
|
||||||
|
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||||
|
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FieldEdited edits the draft and marks dirty', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
|
||||||
|
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MarginEdited edits one edge and marks dirty', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
|
||||||
|
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
|
||||||
|
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||||
|
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||||
|
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
|
||||||
|
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(false);
|
||||||
|
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||||
|
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||||
|
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
|
||||||
|
// a further edit changes the draft reference before the save resolves
|
||||||
|
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||||
|
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits are no-ops in non-loaded states', () => {
|
||||||
|
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({
|
||||||
|
tag: 'loading',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a completed logo upload sets logoDocumentId + dirty', () => {
|
||||||
|
const withCat = reduce(loaded(), {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||||
|
});
|
||||||
|
const selected = reduce(withCat, {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 },
|
||||||
|
});
|
||||||
|
const done = reduce(selected, {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||||
|
});
|
||||||
|
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
|
||||||
|
expect(done.tag === 'loaded' && done.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing the logo clears logoDocumentId + dirty', () => {
|
||||||
|
const withLogo = reduce(loaded(), {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||||
|
});
|
||||||
|
const removed = reduce(withLogo, {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||||
|
});
|
||||||
|
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
|
||||||
|
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
|
||||||
|
const withCat = reduce(loaded(), {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||||
|
});
|
||||||
|
const switched = reduce(withCat, {
|
||||||
|
tag: 'DraftLoaded',
|
||||||
|
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||||
|
});
|
||||||
|
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
|
||||||
|
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
|
||||||
|
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/app/brief/domain/org-template.machine.ts
Normal file
100
src/app/brief/domain/org-template.machine.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { assertNever } from '@shared/kernel/fp';
|
||||||
|
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||||
|
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
||||||
|
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
||||||
|
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
||||||
|
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||||
|
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||||
|
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The org-identity text fields editable directly on the letter canvas. */
|
||||||
|
export type OrgTemplateTextField =
|
||||||
|
| 'orgName'
|
||||||
|
| 'returnAddress'
|
||||||
|
| 'footerContact'
|
||||||
|
| 'footerLegal'
|
||||||
|
| 'signatureName'
|
||||||
|
| 'signatureRole'
|
||||||
|
| 'signatureClosing';
|
||||||
|
|
||||||
|
export type OrgTemplateState =
|
||||||
|
| { tag: 'loading' }
|
||||||
|
| { tag: 'failed'; reason: string }
|
||||||
|
| {
|
||||||
|
tag: 'loaded';
|
||||||
|
subOrgId: string;
|
||||||
|
draft: OrgTemplate;
|
||||||
|
publishedVersion: number;
|
||||||
|
history: readonly OrgTemplateVersion[];
|
||||||
|
unsentBriefs: number;
|
||||||
|
dirty: boolean;
|
||||||
|
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||||
|
upload: UploadState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initial: OrgTemplateState = { tag: 'loading' };
|
||||||
|
|
||||||
|
export type OrgTemplateMsg =
|
||||||
|
| { tag: 'Loading' }
|
||||||
|
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
|
||||||
|
| { tag: 'LoadFailed'; reason: string }
|
||||||
|
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
|
||||||
|
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
|
||||||
|
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||||
|
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||||
|
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||||
|
| { tag: 'Upload'; msg: UploadMsg };
|
||||||
|
|
||||||
|
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||||
|
function editDraft(
|
||||||
|
s: OrgTemplateState,
|
||||||
|
f: (draft: OrgTemplate) => OrgTemplate,
|
||||||
|
): OrgTemplateState {
|
||||||
|
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||||
|
switch (m.tag) {
|
||||||
|
case 'Loading':
|
||||||
|
return { tag: 'loading' };
|
||||||
|
case 'LoadFailed':
|
||||||
|
return { tag: 'failed', reason: m.reason };
|
||||||
|
case 'DraftLoaded':
|
||||||
|
return {
|
||||||
|
tag: 'loaded',
|
||||||
|
subOrgId: m.view.draft.subOrgId,
|
||||||
|
draft: m.view.draft,
|
||||||
|
publishedVersion: m.view.publishedVersion,
|
||||||
|
history: m.view.history,
|
||||||
|
unsentBriefs: m.view.unsentBriefs,
|
||||||
|
dirty: false,
|
||||||
|
// Keep the loaded logo category across sub-org switches (it's the same
|
||||||
|
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||||
|
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||||
|
};
|
||||||
|
case 'FieldEdited':
|
||||||
|
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||||
|
case 'MarginEdited':
|
||||||
|
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||||
|
case 'DraftSaved':
|
||||||
|
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||||
|
case 'Upload': {
|
||||||
|
if (s.tag !== 'loaded') return s;
|
||||||
|
const upload = reduceUpload(s.upload, m.msg);
|
||||||
|
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||||
|
if (m.msg.type === 'UploadComplete')
|
||||||
|
return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true };
|
||||||
|
if (m.msg.type === 'UploadRemoved') {
|
||||||
|
const { logoDocumentId: _dropped, ...rest } = s.draft;
|
||||||
|
return { ...s, upload, draft: rest, dirty: true };
|
||||||
|
}
|
||||||
|
return { ...s, upload };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return assertNever(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,39 @@ export interface OrgTemplate {
|
|||||||
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
||||||
readonly version: number;
|
readonly version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- admin editor (WP-26) ---
|
||||||
|
|
||||||
|
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
||||||
|
export interface OrgTemplateVersion {
|
||||||
|
readonly version: number;
|
||||||
|
readonly publishedAt: string;
|
||||||
|
readonly template: OrgTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
|
||||||
|
export interface OrgTemplateAdminView {
|
||||||
|
readonly draft: OrgTemplate;
|
||||||
|
readonly publishedVersion: number;
|
||||||
|
readonly history: readonly OrgTemplateVersion[];
|
||||||
|
/** How many not-yet-sent letters a publish would re-render (the impact count). */
|
||||||
|
readonly unsentBriefs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the sub-org switcher. */
|
||||||
|
export interface SubOrgSummary {
|
||||||
|
readonly subOrgId: string;
|
||||||
|
readonly orgName: string;
|
||||||
|
readonly publishedVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish outcome: the new version and how many unsent letters it touched. */
|
||||||
|
export interface PublishResult {
|
||||||
|
readonly version: number;
|
||||||
|
readonly affectedUnsentBriefs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
|
||||||
|
feedback via `<input min max>`; the server re-validates and stays the authority. */
|
||||||
|
export const MARGIN_MIN_MM = 10;
|
||||||
|
export const MARGIN_MAX_MM = 50;
|
||||||
|
|||||||
37
src/app/brief/infrastructure/letter-preview.adapter.ts
Normal file
37
src/app/brief/infrastructure/letter-preview.adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||||
|
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
||||||
|
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||||
|
* `roleInterceptor`, so `X-Role` is set here explicitly.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LetterPreviewAdapter {
|
||||||
|
async preview(): Promise<Result<string, Blob>> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||||
|
headers: { 'X-Role': currentRole() },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return err(PREVIEW_FAILED);
|
||||||
|
}
|
||||||
|
if (!res.ok) return err(await errorMessage(res));
|
||||||
|
return ok(await res.blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errorMessage(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||||
|
} catch {
|
||||||
|
return PREVIEW_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/brief/infrastructure/org-template.adapter.spec.ts
Normal file
54
src/app/brief/infrastructure/org-template.adapter.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||||
|
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
||||||
|
|
||||||
|
const draft: OrgTemplateDto = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG',
|
||||||
|
returnAddress: 'Postbus 1',
|
||||||
|
footerContact: 'info@cibg.nl',
|
||||||
|
footerLegal: 'onderdeel van VWS',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: OrgTemplateAdminViewDto = {
|
||||||
|
draft,
|
||||||
|
publishedVersion: 3,
|
||||||
|
unsentBriefs: 2,
|
||||||
|
history: [{ version: 2, publishedAt: '2026-06-01', template: draft }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseOrgTemplateAdminView', () => {
|
||||||
|
it('parses a well-formed admin view', () => {
|
||||||
|
const r = parseOrgTemplateAdminView(view);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.value.draft.orgName).toBe('CIBG');
|
||||||
|
expect(r.value.publishedVersion).toBe(3);
|
||||||
|
expect(r.value.unsentBriefs).toBe(2);
|
||||||
|
expect(r.value.history).toHaveLength(1);
|
||||||
|
expect(r.value.history[0].version).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing draft', () => {
|
||||||
|
const r = parseOrgTemplateAdminView({ ...view, draft: undefined });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing count field', () => {
|
||||||
|
const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed history entry', () => {
|
||||||
|
const r = parseOrgTemplateAdminView({
|
||||||
|
...view,
|
||||||
|
history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }],
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
src/app/brief/infrastructure/org-template.adapter.ts
Normal file
163
src/app/brief/infrastructure/org-template.adapter.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { runSubmit } from '@shared/application/submit';
|
||||||
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
ApiClient,
|
||||||
|
OrgTemplateAdminViewDto,
|
||||||
|
OrgTemplateDto,
|
||||||
|
OrgTemplateVersionDto,
|
||||||
|
PublishOrgTemplateResponse,
|
||||||
|
SubOrgSummaryDto,
|
||||||
|
} from '@shared/infrastructure/api-client';
|
||||||
|
import {
|
||||||
|
OrgTemplate,
|
||||||
|
OrgTemplateAdminView,
|
||||||
|
OrgTemplateVersion,
|
||||||
|
PublishResult,
|
||||||
|
SubOrgSummary,
|
||||||
|
} from '@brief/domain/org-template';
|
||||||
|
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
|
||||||
|
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||||
|
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||||
|
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||||
|
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class OrgTemplateAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
const out: SubOrgSummary[] = [];
|
||||||
|
for (const s of r.value ?? []) {
|
||||||
|
const parsed = parseSubOrg(s);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
out.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
||||||
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
|
const r = await runSubmit(
|
||||||
|
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
|
||||||
|
FAILED,
|
||||||
|
);
|
||||||
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
|
||||||
|
return r.ok ? parsePublish(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollback(
|
||||||
|
subOrgId: string,
|
||||||
|
version: number,
|
||||||
|
): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
|
||||||
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
|
||||||
|
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||||
|
{ headers: { 'X-Role': currentRole() } },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return err(PROEFBRIEF_FAILED);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
try {
|
||||||
|
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
||||||
|
} catch {
|
||||||
|
return err(PROEFBRIEF_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok(await res.blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse: wire → domain, validating at the boundary ---
|
||||||
|
|
||||||
|
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
||||||
|
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
|
||||||
|
return err('sub-org: bad shape');
|
||||||
|
return ok({
|
||||||
|
subOrgId: dto.subOrgId,
|
||||||
|
orgName: dto.orgName,
|
||||||
|
publishedVersion: dto.publishedVersion ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
|
||||||
|
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
|
||||||
|
return err('version: bad shape');
|
||||||
|
const template = parseOrgTemplate(dto.template);
|
||||||
|
if (!template.ok) return template;
|
||||||
|
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgTemplateAdminView(
|
||||||
|
dto: OrgTemplateAdminViewDto,
|
||||||
|
): Result<string, OrgTemplateAdminView> {
|
||||||
|
const draft = parseOrgTemplate(dto.draft);
|
||||||
|
if (!draft.ok) return draft;
|
||||||
|
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
|
||||||
|
return err('admin-view: bad shape');
|
||||||
|
const history: OrgTemplateVersion[] = [];
|
||||||
|
for (const v of dto.history ?? []) {
|
||||||
|
const parsed = parseVersion(v);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
history.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
draft: draft.value,
|
||||||
|
publishedVersion: dto.publishedVersion,
|
||||||
|
history,
|
||||||
|
unsentBriefs: dto.unsentBriefs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAdminView = parseOrgTemplateAdminView;
|
||||||
|
|
||||||
|
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
|
||||||
|
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
|
||||||
|
return err('publish: bad shape');
|
||||||
|
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- toDto: domain → wire (for save) ---
|
||||||
|
|
||||||
|
function toDto(t: OrgTemplate): OrgTemplateDto {
|
||||||
|
return {
|
||||||
|
subOrgId: t.subOrgId,
|
||||||
|
orgName: t.orgName,
|
||||||
|
returnAddress: t.returnAddress,
|
||||||
|
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
|
||||||
|
footerContact: t.footerContact,
|
||||||
|
footerLegal: t.footerLegal,
|
||||||
|
signatureName: t.signatureName,
|
||||||
|
signatureRole: t.signatureRole,
|
||||||
|
signatureClosing: t.signatureClosing,
|
||||||
|
margins: { ...t.margins },
|
||||||
|
version: t.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
<app-letter-composer
|
<app-letter-composer
|
||||||
[brief]="s.brief"
|
[brief]="s.brief"
|
||||||
[orgTemplate]="orgTemplate"
|
[orgTemplate]="orgTemplate"
|
||||||
|
[logoUrl]="store.logoUrl()"
|
||||||
[availablePassages]="s.availablePassages"
|
[availablePassages]="s.availablePassages"
|
||||||
[diagnostics]="store.diagnostics()"
|
[diagnostics]="store.diagnostics()"
|
||||||
[canEdit]="store.canEdit()"
|
[canEdit]="store.canEdit()"
|
||||||
@@ -63,6 +64,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
(approve)="store.approve()"
|
(approve)="store.approve()"
|
||||||
(reject)="store.reject($event)"
|
(reject)="store.reject($event)"
|
||||||
(send)="store.send()"
|
(send)="store.send()"
|
||||||
|
(preview)="store.previewLetter()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { formatDatumNl } from '@shared/kernel/datum';
|
|||||||
import { Paragraph } from '@shared/kernel/rich-text';
|
import { Paragraph } from '@shared/kernel/rich-text';
|
||||||
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
|
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||||
@@ -70,26 +71,47 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
}
|
}
|
||||||
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
||||||
.surface {
|
.surface {
|
||||||
background: var(--rhc-color-grijs-100, #f0f0f0);
|
background: var(--rhc-color-grijs-100);
|
||||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
border-radius: var(--rhc-border-radius-md);
|
border-radius: var(--rhc-border-radius-md);
|
||||||
padding: var(--rhc-space-max-lg);
|
padding: var(--rhc-space-max-lg);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.surface .letter {
|
.surface .letter {
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15);
|
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
|
||||||
}
|
}
|
||||||
/* Org-identity regions: visibly not the drafter's to edit. */
|
/* Org-identity regions: visibly not the drafter's to edit. */
|
||||||
.from-template {
|
.from-template {
|
||||||
background: var(--rhc-color-grijs-100, #f5f5f5);
|
background: var(--rhc-color-grijs-100);
|
||||||
outline: 2mm solid var(--rhc-color-grijs-100, #f5f5f5);
|
outline: 2mm solid var(--rhc-color-grijs-100);
|
||||||
}
|
}
|
||||||
.from-template-caption {
|
.from-template-caption {
|
||||||
font-size: 7.5pt;
|
font-size: 7.5pt;
|
||||||
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
|
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
|
||||||
color: var(--rhc-color-grijs-700, #334155);
|
color: var(--rhc-color-grijs-700);
|
||||||
margin: 0 0 2mm;
|
margin: 0 0 2mm;
|
||||||
}
|
}
|
||||||
|
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
|
||||||
|
become controls styled to sit in the letter, with a visible editable affordance. */
|
||||||
|
.tmpl-input,
|
||||||
|
.tmpl-textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--rhc-color-geel-100);
|
||||||
|
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
|
||||||
|
border-radius: var(--rhc-border-radius-sm);
|
||||||
|
padding: 0.5mm 1mm;
|
||||||
|
}
|
||||||
|
.tmpl-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.org-logo {
|
||||||
|
max-height: 20mm;
|
||||||
|
max-width: 60mm;
|
||||||
|
margin-block-end: 3mm;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@@ -137,8 +159,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
@if (tintTemplate()) {
|
@if (tintTemplate()) {
|
||||||
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
|
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
|
||||||
}
|
}
|
||||||
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
|
@if (logoUrl()) {
|
||||||
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
|
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
||||||
|
}
|
||||||
|
@if (editing()) {
|
||||||
|
<input
|
||||||
|
class="tmpl-input org-wordmark"
|
||||||
|
[value]="orgTemplate().orgName"
|
||||||
|
[attr.aria-label]="orgNameLabel()"
|
||||||
|
(input)="emitEdit('orgName', $event)"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
class="tmpl-textarea return-address"
|
||||||
|
rows="2"
|
||||||
|
[value]="orgTemplate().returnAddress"
|
||||||
|
[attr.aria-label]="returnAddressLabel()"
|
||||||
|
(input)="emitEdit('returnAddress', $event)"
|
||||||
|
></textarea>
|
||||||
|
} @else {
|
||||||
|
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
|
||||||
|
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
|
||||||
|
}
|
||||||
<address class="address-window">{{ recipientText() }}</address>
|
<address class="address-window">{{ recipientText() }}</address>
|
||||||
<dl class="reference">
|
<dl class="reference">
|
||||||
<div>
|
<div>
|
||||||
@@ -209,14 +250,51 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="letter__signature" [class.from-template]="tintTemplate()">
|
<div class="letter__signature" [class.from-template]="tintTemplate()">
|
||||||
<p>{{ orgTemplate().signatureClosing }}</p>
|
@if (editing()) {
|
||||||
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
|
<input
|
||||||
<p>{{ orgTemplate().signatureRole }}</p>
|
class="tmpl-input"
|
||||||
|
[value]="orgTemplate().signatureClosing"
|
||||||
|
[attr.aria-label]="signatureClosingLabel()"
|
||||||
|
(input)="emitEdit('signatureClosing', $event)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="tmpl-input signature-name"
|
||||||
|
[value]="orgTemplate().signatureName"
|
||||||
|
[attr.aria-label]="signatureNameLabel()"
|
||||||
|
(input)="emitEdit('signatureName', $event)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="tmpl-input"
|
||||||
|
[value]="orgTemplate().signatureRole"
|
||||||
|
[attr.aria-label]="signatureRoleLabel()"
|
||||||
|
(input)="emitEdit('signatureRole', $event)"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<p>{{ orgTemplate().signatureClosing }}</p>
|
||||||
|
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
|
||||||
|
<p>{{ orgTemplate().signatureRole }}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="letter__footer" [class.from-template]="tintTemplate()">
|
<div class="letter__footer" [class.from-template]="tintTemplate()">
|
||||||
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
|
@if (editing()) {
|
||||||
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
|
<textarea
|
||||||
|
class="tmpl-textarea footer-contact"
|
||||||
|
rows="2"
|
||||||
|
[value]="orgTemplate().footerContact"
|
||||||
|
[attr.aria-label]="footerContactLabel()"
|
||||||
|
(input)="emitEdit('footerContact', $event)"
|
||||||
|
></textarea>
|
||||||
|
<input
|
||||||
|
class="tmpl-input footer-legal"
|
||||||
|
[value]="orgTemplate().footerLegal"
|
||||||
|
[attr.aria-label]="footerLegalLabel()"
|
||||||
|
(input)="emitEdit('footerLegal', $event)"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
|
||||||
|
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@for (top of pageBreaks(); track $index) {
|
@for (top of pageBreaks(); track $index) {
|
||||||
@@ -238,7 +316,11 @@ export class LetterCanvasComponent {
|
|||||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
zoom = input(1);
|
zoom = input(1);
|
||||||
|
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||||
|
logoUrl = input<string | null>(null);
|
||||||
edit = output<BriefMsg>();
|
edit = output<BriefMsg>();
|
||||||
|
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||||
|
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||||
|
|
||||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||||
@@ -253,6 +335,14 @@ export class LetterCanvasComponent {
|
|||||||
);
|
);
|
||||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
||||||
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
||||||
|
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
|
||||||
|
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
|
||||||
|
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
|
||||||
|
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
|
||||||
|
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
|
||||||
|
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
|
||||||
|
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
|
||||||
|
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
|
||||||
|
|
||||||
protected showSample = signal(false);
|
protected showSample = signal(false);
|
||||||
protected letterDate = formatDatumNl(new Date());
|
protected letterDate = formatDatumNl(new Date());
|
||||||
@@ -260,6 +350,12 @@ export class LetterCanvasComponent {
|
|||||||
/** The letterhead/signature/footer are tinted "not yours" only while composing —
|
/** The letterhead/signature/footer are tinted "not yours" only while composing —
|
||||||
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
|
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
|
||||||
protected tintTemplate = computed(() => this.editableRegions() === 'content');
|
protected tintTemplate = computed(() => this.editableRegions() === 'content');
|
||||||
|
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||||
|
protected editing = computed(() => this.editableRegions() === 'template');
|
||||||
|
|
||||||
|
protected emitEdit(field: OrgTemplateTextField, event: Event) {
|
||||||
|
this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value });
|
||||||
|
}
|
||||||
|
|
||||||
protected marginStyle = computed(() => {
|
protected marginStyle = computed(() => {
|
||||||
const m = this.orgTemplate().margins;
|
const m = this.orgTemplate().margins;
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-block-end: var(--rhc-space-max-lg);
|
margin-block-end: var(--rhc-space-max-lg);
|
||||||
}
|
}
|
||||||
|
.head-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
}
|
||||||
.panel {
|
.panel {
|
||||||
margin-block: var(--rhc-space-max-xl);
|
margin-block: var(--rhc-space-max-xl);
|
||||||
}
|
}
|
||||||
@@ -56,7 +61,12 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
template: `
|
template: `
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<app-heading [level]="2">{{ title() }}</app-heading>
|
<app-heading [level]="2">{{ title() }}</app-heading>
|
||||||
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
<div class="head-end">
|
||||||
|
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
|
||||||
|
previewLabel()
|
||||||
|
}}</app-button>
|
||||||
|
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (status() === 'rejected') {
|
@if (status() === 'rejected') {
|
||||||
@@ -66,6 +76,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
<app-letter-canvas
|
<app-letter-canvas
|
||||||
[brief]="brief()"
|
[brief]="brief()"
|
||||||
[orgTemplate]="orgTemplate()"
|
[orgTemplate]="orgTemplate()"
|
||||||
|
[logoUrl]="logoUrl()"
|
||||||
[editableRegions]="canEdit() ? 'content' : 'none'"
|
[editableRegions]="canEdit() ? 'content' : 'none'"
|
||||||
[availablePassages]="availablePassages()"
|
[availablePassages]="availablePassages()"
|
||||||
[placeholders]="menu()"
|
[placeholders]="menu()"
|
||||||
@@ -129,6 +140,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
export class LetterComposerComponent {
|
export class LetterComposerComponent {
|
||||||
brief = input.required<Brief>();
|
brief = input.required<Brief>();
|
||||||
orgTemplate = input.required<OrgTemplate>();
|
orgTemplate = input.required<OrgTemplate>();
|
||||||
|
logoUrl = input<string | null>(null);
|
||||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
canEdit = input(false);
|
canEdit = input(false);
|
||||||
@@ -143,9 +155,11 @@ export class LetterComposerComponent {
|
|||||||
approve = output<void>();
|
approve = output<void>();
|
||||||
reject = output<string>();
|
reject = output<string>();
|
||||||
send = output<void>();
|
send = output<void>();
|
||||||
|
preview = output<void>();
|
||||||
locate = output<Diagnostic>();
|
locate = output<Diagnostic>();
|
||||||
|
|
||||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||||
|
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||||
submitHint = input(
|
submitHint = input(
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
||||||
|
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
||||||
|
import { UploadState } from '@shared/upload/upload.machine';
|
||||||
|
import { Brief } from '@brief/domain/brief';
|
||||||
|
import {
|
||||||
|
MARGIN_MAX_MM,
|
||||||
|
MARGIN_MIN_MM,
|
||||||
|
Margins,
|
||||||
|
OrgTemplate,
|
||||||
|
OrgTemplateVersion,
|
||||||
|
SubOrgSummary,
|
||||||
|
} from '@brief/domain/org-template';
|
||||||
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
|
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||||
|
|
||||||
|
const LOGO_CATEGORY = 'org-logo';
|
||||||
|
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
||||||
|
|
||||||
|
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||||
|
while editing (content itself is not the admin's to change). */
|
||||||
|
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||||
|
briefId: 'VOORBEELD-0001',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 'sample',
|
||||||
|
drafterId: 'sample',
|
||||||
|
status: { tag: 'draft' },
|
||||||
|
placeholders: [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'body',
|
||||||
|
title: 'Voorbeeldinhoud',
|
||||||
|
required: true,
|
||||||
|
locked: true,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'freeText',
|
||||||
|
blockId: 'sample-1',
|
||||||
|
content: {
|
||||||
|
paragraphs: [
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ type: 'text', text: 'Geachte ' },
|
||||||
|
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||||
|
{ type: 'text', text: ',' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
|
||||||
|
* composer — the letter canvas runs in `editableRegions='template'` so the
|
||||||
|
* letterhead/signature/footer are edited in place, while the content is a read-only
|
||||||
|
* sample. Margins, logo upload, version history and the publish bar sit around it.
|
||||||
|
* Presentational: every mutation is an output the store turns into a command.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-org-template-editor',
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
HeadingComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
AlertComponent,
|
||||||
|
FileInputComponent,
|
||||||
|
SingleUploadComponent,
|
||||||
|
LetterCanvasComponent,
|
||||||
|
],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-block-end: var(--rhc-space-max-lg);
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--rhc-space-max-2xs);
|
||||||
|
}
|
||||||
|
.save {
|
||||||
|
color: var(--rhc-color-foreground-subtle);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
.margins {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
|
border-radius: var(--rhc-border-radius-md);
|
||||||
|
padding: var(--rhc-space-max-md);
|
||||||
|
}
|
||||||
|
.margins input {
|
||||||
|
width: 6rem;
|
||||||
|
}
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--rhc-space-max-sm);
|
||||||
|
}
|
||||||
|
.history-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
|
padding-block-end: var(--rhc-space-max-sm);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
align-items: center;
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
.published {
|
||||||
|
color: var(--rhc-color-foreground-subtle);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ subOrgLabel() }}</span>
|
||||||
|
<select class="form-select" (change)="onSelectSubOrg($event)">
|
||||||
|
@for (o of subOrgs(); track o.subOrgId) {
|
||||||
|
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
||||||
|
{{ o.orgName }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-letter-canvas
|
||||||
|
[brief]="sampleBrief()"
|
||||||
|
[orgTemplate]="draft()"
|
||||||
|
[logoUrl]="logoUrl()"
|
||||||
|
editableRegions="template"
|
||||||
|
(templateEdit)="templateEdit.emit($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset class="section margins">
|
||||||
|
<legend>{{ marginsLegend() }}</legend>
|
||||||
|
@for (edge of edges; track edge) {
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ edgeLabel(edge) }}</span>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="number"
|
||||||
|
[min]="MIN"
|
||||||
|
[max]="MAX"
|
||||||
|
[value]="draft().margins[edge]"
|
||||||
|
(input)="onMargin(edge, $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
|
||||||
|
@if (logoCategory()) {
|
||||||
|
<app-file-input
|
||||||
|
inputId="org-logo-input"
|
||||||
|
[accept]="logoCategory()!.acceptedTypes"
|
||||||
|
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||||
|
[label]="logoHeading()"
|
||||||
|
(filesSelected)="logoSelected.emit($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (logoRejection()) {
|
||||||
|
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||||
|
}
|
||||||
|
@if (logoUploads().length) {
|
||||||
|
<ul class="file-list">
|
||||||
|
@for (u of logoUploads(); track u.localId) {
|
||||||
|
<li
|
||||||
|
app-single-upload
|
||||||
|
[upload]="u"
|
||||||
|
[previewUrlFor]="previewUrlFor()"
|
||||||
|
(remove)="logoRemoved.emit(u.localId)"
|
||||||
|
(retry)="logoRetry.emit(u.localId)"
|
||||||
|
></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
|
||||||
|
@if (history().length === 0) {
|
||||||
|
<p class="published">{{ noHistory() }}</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="history-list">
|
||||||
|
@for (v of history(); track v.version) {
|
||||||
|
<li class="history-row">
|
||||||
|
<span>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span>
|
||||||
|
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
||||||
|
{{ rollbackLabel() }}
|
||||||
|
</app-button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
|
||||||
|
@if (pendingPublish()) {
|
||||||
|
<app-alert type="warning">{{ impactText() }}</app-alert>
|
||||||
|
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
|
||||||
|
{{ confirmLabel() }}
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
|
||||||
|
{{ cancelLabel() }}
|
||||||
|
</app-button>
|
||||||
|
} @else {
|
||||||
|
<app-button
|
||||||
|
variant="primary"
|
||||||
|
[disabled]="!draftValid() || busy()"
|
||||||
|
(click)="requestPublish.emit()"
|
||||||
|
>
|
||||||
|
{{ publishLabel() }}
|
||||||
|
</app-button>
|
||||||
|
@if (!draftValid()) {
|
||||||
|
<span class="published">{{ invalidHint() }}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
|
||||||
|
{{ proefbriefLabel() }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class OrgTemplateEditorComponent {
|
||||||
|
draft = input.required<OrgTemplate>();
|
||||||
|
logoUrl = input<string | null>(null);
|
||||||
|
uploadState = input.required<UploadState>();
|
||||||
|
subOrgs = input<readonly SubOrgSummary[]>([]);
|
||||||
|
selectedSubOrgId = input<string | null>(null);
|
||||||
|
history = input<readonly OrgTemplateVersion[]>([]);
|
||||||
|
publishedVersion = input(0);
|
||||||
|
unsentBriefs = input(0);
|
||||||
|
draftValid = input(false);
|
||||||
|
busy = input(false);
|
||||||
|
pendingPublish = input(false);
|
||||||
|
saveText = input('');
|
||||||
|
sampleBrief = input<Brief>(SAMPLE_LETTER_BRIEF);
|
||||||
|
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||||
|
|
||||||
|
selectSubOrg = output<string>();
|
||||||
|
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||||
|
marginEdit = output<{ edge: keyof Margins; value: number }>();
|
||||||
|
logoSelected = output<File[]>();
|
||||||
|
logoRemoved = output<string>();
|
||||||
|
logoRetry = output<string>();
|
||||||
|
requestPublish = output<void>();
|
||||||
|
confirmPublish = output<void>();
|
||||||
|
cancelPublish = output<void>();
|
||||||
|
rollback = output<number>();
|
||||||
|
proefbrief = output<void>();
|
||||||
|
|
||||||
|
protected readonly edges = EDGES;
|
||||||
|
protected readonly MIN = MARGIN_MIN_MM;
|
||||||
|
protected readonly MAX = MARGIN_MAX_MM;
|
||||||
|
|
||||||
|
protected logoCategory = computed(() =>
|
||||||
|
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||||
|
);
|
||||||
|
protected logoUploads = computed(() =>
|
||||||
|
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||||
|
);
|
||||||
|
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||||
|
|
||||||
|
protected onSelectSubOrg(event: Event) {
|
||||||
|
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
||||||
|
}
|
||||||
|
protected onMargin(edge: keyof Margins, event: Event) {
|
||||||
|
const value = (event.target as HTMLInputElement).valueAsNumber;
|
||||||
|
if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected edgeLabel(edge: keyof Margins): string {
|
||||||
|
switch (edge) {
|
||||||
|
case 'topMm':
|
||||||
|
return $localize`:@@orgTemplate.margin.top:Boven (mm)`;
|
||||||
|
case 'rightMm':
|
||||||
|
return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;
|
||||||
|
case 'bottomMm':
|
||||||
|
return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;
|
||||||
|
case 'leftMm':
|
||||||
|
return $localize`:@@orgTemplate.margin.left:Links (mm)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected impactText = computed(() =>
|
||||||
|
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||||
|
);
|
||||||
|
|
||||||
|
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
||||||
|
protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`);
|
||||||
|
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
||||||
|
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
||||||
|
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
||||||
|
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
|
||||||
|
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
|
||||||
|
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
|
||||||
|
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
|
||||||
|
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
|
||||||
|
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
|
||||||
|
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
|
||||||
|
protected invalidHint = input(
|
||||||
|
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
||||||
|
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
||||||
|
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
|
const draft: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||||
|
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subOrgs: SubOrgSummary[] = [
|
||||||
|
{ subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', publishedVersion: 3 },
|
||||||
|
{ subOrgId: 'cibg-vakbekwaamheid', orgName: 'CIBG — Vakbekwaamheid', publishedVersion: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const history: OrgTemplateVersion[] = [
|
||||||
|
{ version: 3, publishedAt: '2026-06-20', template: draft },
|
||||||
|
{ version: 2, publishedAt: '2026-05-11', template: draft },
|
||||||
|
];
|
||||||
|
|
||||||
|
const uploadWithCategory: UploadState = {
|
||||||
|
...initialUpload,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
categoryId: 'org-logo',
|
||||||
|
label: 'Logo',
|
||||||
|
description: 'Logo van de organisatie',
|
||||||
|
required: false,
|
||||||
|
acceptedTypes: ['image/png', 'image/jpeg'],
|
||||||
|
maxSizeMb: 2,
|
||||||
|
multiple: false,
|
||||||
|
allowPostDelivery: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<OrgTemplateEditorComponent> = {
|
||||||
|
title: 'Domein/Brief/Org Template Editor',
|
||||||
|
component: OrgTemplateEditorComponent,
|
||||||
|
args: {
|
||||||
|
draft,
|
||||||
|
logoUrl: null,
|
||||||
|
uploadState: uploadWithCategory,
|
||||||
|
subOrgs,
|
||||||
|
selectedSubOrgId: 'cibg-registers',
|
||||||
|
history,
|
||||||
|
publishedVersion: 3,
|
||||||
|
unsentBriefs: 4,
|
||||||
|
draftValid: true,
|
||||||
|
busy: false,
|
||||||
|
pendingPublish: false,
|
||||||
|
saveText: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<OrgTemplateEditorComponent>;
|
||||||
|
|
||||||
|
export const Editing: Story = {};
|
||||||
|
|
||||||
|
export const PublishConfirm: Story = {
|
||||||
|
args: { pendingPublish: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Invalid: Story = {
|
||||||
|
args: {
|
||||||
|
draft: { ...draft, orgName: '', margins: { ...draft.margins, topMm: 5 } },
|
||||||
|
draftValid: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoHistory: Story = {
|
||||||
|
args: { history: [], publishedVersion: 0 },
|
||||||
|
};
|
||||||
128
src/app/brief/ui/org-template.page.ts
Normal file
128
src/app/brief/ui/org-template.page.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||||
|
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
||||||
|
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
||||||
|
|
||||||
|
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
|
||||||
|
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
|
||||||
|
for admins. Loads once the capability resolves; wires store commands to the organism. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-org-template-page',
|
||||||
|
imports: [
|
||||||
|
PageShellComponent,
|
||||||
|
AlertComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
...ASYNC,
|
||||||
|
OrgTemplateEditorComponent,
|
||||||
|
],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.save {
|
||||||
|
color: var(--rhc-color-foreground-subtle);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-page-shell [heading]="heading" [intro]="intro" backLink="/brief">
|
||||||
|
@if (store.lastError(); as err) {
|
||||||
|
<app-alert type="error">{{ err }}</app-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!access.ready()) {
|
||||||
|
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
|
||||||
|
} @else if (!canEdit()) {
|
||||||
|
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||||
|
} @else {
|
||||||
|
<app-async [data]="store.remoteData()">
|
||||||
|
<ng-template appAsyncError>
|
||||||
|
<app-alert type="error">{{ failedText }}</app-alert>
|
||||||
|
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncLoaded>
|
||||||
|
@if (store.draft(); as draft) {
|
||||||
|
<app-org-template-editor
|
||||||
|
[draft]="draft"
|
||||||
|
[logoUrl]="store.logoUrl()"
|
||||||
|
[uploadState]="store.uploadState()"
|
||||||
|
[subOrgs]="store.subOrgs()"
|
||||||
|
[selectedSubOrgId]="store.selectedSubOrgId()"
|
||||||
|
[history]="store.history()"
|
||||||
|
[publishedVersion]="store.publishedVersion()"
|
||||||
|
[unsentBriefs]="store.unsentBriefs()"
|
||||||
|
[draftValid]="store.draftValid()"
|
||||||
|
[busy]="store.busy()"
|
||||||
|
[pendingPublish]="store.pendingPublish()"
|
||||||
|
[saveText]="saveText()"
|
||||||
|
[previewUrlFor]="previewUrlFor"
|
||||||
|
(selectSubOrg)="store.selectSubOrg($event)"
|
||||||
|
(templateEdit)="store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })"
|
||||||
|
(marginEdit)="store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })"
|
||||||
|
(logoSelected)="store.onLogoSelected($event)"
|
||||||
|
(logoRemoved)="store.onLogoRemoved($event)"
|
||||||
|
(logoRetry)="store.onLogoRetry($event)"
|
||||||
|
(requestPublish)="store.requestPublish()"
|
||||||
|
(confirmPublish)="store.confirmPublish()"
|
||||||
|
(cancelPublish)="store.cancelPublish()"
|
||||||
|
(rollback)="store.rollback($event)"
|
||||||
|
(proefbrief)="store.proefbrief()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
|
}
|
||||||
|
</app-page-shell>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class OrgTemplatePage {
|
||||||
|
protected store = inject(OrgTemplateStore);
|
||||||
|
protected access = inject(AccessStore);
|
||||||
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
|
|
||||||
|
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
||||||
|
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
|
||||||
|
|
||||||
|
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
||||||
|
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
||||||
|
protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;
|
||||||
|
protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;
|
||||||
|
protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;
|
||||||
|
|
||||||
|
private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;
|
||||||
|
private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;
|
||||||
|
private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;
|
||||||
|
protected saveText = computed(() => {
|
||||||
|
switch (this.store.saveState().tag) {
|
||||||
|
case 'Saving':
|
||||||
|
return this.savingText;
|
||||||
|
case 'Saved':
|
||||||
|
return this.savedText;
|
||||||
|
case 'Error':
|
||||||
|
return this.saveErrorText;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private loadRequested = false;
|
||||||
|
constructor() {
|
||||||
|
// Load once the capability resolves to `allowed` (a 403 GET would be wasted
|
||||||
|
// otherwise). Depends only on `canEdit()` + a plain flag — never on the store
|
||||||
|
// model, so dispatching `Loading` inside `load()` can't retrigger this effect.
|
||||||
|
effect(() => {
|
||||||
|
if (this.canEdit() && !this.loadRequested) {
|
||||||
|
this.loadRequested = true;
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reload() {
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief';
|
|||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--rhc-color-wit, #fff);
|
background: var(--rhc-color-wit);
|
||||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
border-radius: var(--rhc-border-radius-md);
|
border-radius: var(--rhc-border-radius-md);
|
||||||
padding: var(--rhc-space-max-md);
|
padding: var(--rhc-space-max-md);
|
||||||
|
|||||||
@@ -33,4 +33,11 @@ export class AccessStore {
|
|||||||
const rd = this.capabilities();
|
const rd = this.capabilities();
|
||||||
return rd.tag === 'Success' && rd.value.includes(capability);
|
return rd.tag === 'Success' && rd.value.includes(capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True once `/me` has resolved (success or failure) — lets a page-level gate tell
|
||||||
|
"still loading" apart from "denied", so an admin doesn't flash the denial alert. */
|
||||||
|
readonly ready = computed(() => {
|
||||||
|
const tag = this.capabilities().tag;
|
||||||
|
return tag === 'Success' || tag === 'Failure';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export interface XhrUploadHandle {
|
|||||||
/** Sentinel rejection so the caller can tell a user-cancel from a real failure. */
|
/** Sentinel rejection so the caller can tell a user-cancel from a real failure. */
|
||||||
export const UPLOAD_ABORTED = Symbol('upload-aborted');
|
export const UPLOAD_ABORTED = Symbol('upload-aborted');
|
||||||
|
|
||||||
|
/** Direct URL to a stored document's bytes. Pure (no injection) so a store can build
|
||||||
|
a letterhead-logo `src` without pulling `ApiClient` into its dependency graph. */
|
||||||
|
export function uploadContentUrl(documentId: string): string {
|
||||||
|
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure: the only place upload HTTP lives. Reads (categories, status,
|
* Infrastructure: the only place upload HTTP lives. Reads (categories, status,
|
||||||
* delete) go through the NSwag client; the multipart POST is hand-written XHR
|
* delete) go through the NSwag client; the multipart POST is hand-written XHR
|
||||||
@@ -94,7 +100,7 @@ export class UploadAdapter {
|
|||||||
* browser opens it. demo-* ids (dev simulation) have no bytes and 404 here.
|
* browser opens it. demo-* ids (dev simulation) have no bytes and 404 here.
|
||||||
*/
|
*/
|
||||||
contentUrl(documentId: string): string {
|
contentUrl(documentId: string): string {
|
||||||
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`;
|
return uploadContentUrl(documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user