Compare commits
4 Commits
44eb2d2186
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c837f281 | |||
| 1bb9383344 | |||
| c07a33ee3e | |||
| 5a610c10f0 |
@@ -1,3 +1,5 @@
|
||||
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
|
||||
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
|
||||
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
|
||||
<link rel="stylesheet" href="letter.css" />
|
||||
|
||||
@@ -151,10 +151,43 @@ public sealed record BriefDto(
|
||||
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
|
||||
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend);
|
||||
|
||||
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions);
|
||||
// The brief's screen DTO also carries the org template it renders with (WP-23):
|
||||
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
|
||||
// send time (sent letters are immutable; a republish never re-renders them).
|
||||
public sealed record BriefViewDto(
|
||||
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
|
||||
OrgTemplateDto OrgTemplate);
|
||||
|
||||
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||
public sealed record RejectBriefRequest(string Comments);
|
||||
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
|
||||
public sealed record MeDto(IReadOnlyList<string> Capabilities);
|
||||
|
||||
// --- Organization templates (WP-23, Brief v2 PRD §3) ---
|
||||
// The second template axis: appearance/identity per sub-organization (letterhead,
|
||||
// footer, signature, margins). Orthogonal to the case-type template (sections +
|
||||
// placeholders); the two only meet at render time.
|
||||
|
||||
public sealed record MarginsDto(int TopMm, int RightMm, int BottomMm, int LeftMm);
|
||||
|
||||
// Version: 0 = a draft (work in progress), n>0 = the published snapshot it is.
|
||||
public sealed record OrgTemplateDto(
|
||||
string SubOrgId, string OrgName, string ReturnAddress, string? LogoDocumentId,
|
||||
string FooterContact, string FooterLegal,
|
||||
string SignatureName, string SignatureRole, string SignatureClosing,
|
||||
MarginsDto Margins, int Version = 0);
|
||||
|
||||
public sealed record OrgTemplateVersionDto(int Version, string PublishedAt, OrgTemplateDto Template);
|
||||
|
||||
// Screen DTO for the admin editor: the editable draft, what's live, the append-only
|
||||
// history, and the publish-impact count ("dit raakt N nog niet verzonden brieven").
|
||||
public sealed record OrgTemplateAdminViewDto(
|
||||
OrgTemplateDto Draft, int PublishedVersion,
|
||||
IReadOnlyList<OrgTemplateVersionDto> History, int UnsentBriefs);
|
||||
|
||||
public sealed record SubOrgSummaryDto(string SubOrgId, string OrgName, int PublishedVersion);
|
||||
|
||||
public sealed record SaveOrgTemplateRequest(OrgTemplateDto Draft);
|
||||
|
||||
public sealed record PublishOrgTemplateResponse(int Version, int AffectedUnsentBriefs);
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -47,6 +48,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
|
||||
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrgTemplateEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.SubOrgId);
|
||||
e.Property(t => t.Draft).HasConversion(Json<OrgTemplateDto>());
|
||||
e.Property(t => t.History).HasConversion(Json<List<OrgTemplateVersionDto>>());
|
||||
});
|
||||
}
|
||||
|
||||
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Letters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
@@ -22,6 +23,13 @@ public sealed class BriefEntity
|
||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||
public BriefStatusDto Status { get; set; } = new("draft");
|
||||
/// Which sub-organization's org template themes this letter (WP-23).
|
||||
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
|
||||
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
|
||||
public int? SentOrgTemplateVersion { get; set; }
|
||||
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint
|
||||
/// serves this verbatim, so a later org-template republish never re-renders it.
|
||||
public string? ArchivedHtml { get; set; }
|
||||
|
||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||
}
|
||||
@@ -33,6 +41,7 @@ public static class BriefStore
|
||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||
public const string DrafterId = "demo-drafter";
|
||||
public const string ApproverId = "demo-approver";
|
||||
public const string AdminId = "demo-admin";
|
||||
|
||||
public enum Outcome { Ok, Forbidden, Conflict }
|
||||
|
||||
@@ -102,6 +111,13 @@ public static class BriefStore
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
// Pin the org-template version the letter was sent with (WP-23): from here on
|
||||
// its appearance is frozen — republishing the template touches unsent briefs only.
|
||||
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
|
||||
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
|
||||
// serves this verbatim once sent, so a later republish never re-renders it.
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
|
||||
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
|
||||
223
backend/src/BigRegister.Api/Data/Migrations/20260705085857_OrgTemplates.Designer.cs
generated
Normal file
223
backend/src/BigRegister.Api/Data/Migrations/20260705085857_OrgTemplates.Designer.cs
generated
Normal file
@@ -0,0 +1,223 @@
|
||||
// <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("20260705085857_OrgTemplates")]
|
||||
partial class OrgTemplates
|
||||
{
|
||||
/// <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>("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,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OrgTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SentOrgTemplateVersion",
|
||||
table: "Briefs",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SubOrgId",
|
||||
table: "Briefs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
// Briefs from before this migration adopt the seeded default sub-org.
|
||||
defaultValue: "cibg-registers");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrgTemplates",
|
||||
columns: table => new
|
||||
{
|
||||
SubOrgId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Draft = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PublishedVersion = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
History = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrgTemplates", x => x.SubOrgId);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrgTemplates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SentOrgTemplateVersion",
|
||||
table: "Briefs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubOrgId",
|
||||
table: "Briefs");
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArchivedHtml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Beroep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -124,10 +127,17 @@ namespace BigRegister.Api.Data.Migrations
|
||||
.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");
|
||||
@@ -140,6 +150,27 @@ namespace BigRegister.Api.Data.Migrations
|
||||
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")
|
||||
|
||||
204
backend/src/BigRegister.Api/Data/OrgTemplateStore.cs
Normal file
204
backend/src/BigRegister.Api/Data/OrgTemplateStore.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Organization template per sub-organization (WP-23, Brief v2 PRD §3): one row per
|
||||
/// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the
|
||||
/// append-only list of published snapshots, `PublishedVersion` points into it.
|
||||
/// Rollback copies an old snapshot back into the draft — it never rewrites history.
|
||||
/// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call,
|
||||
/// nested DTO shapes stored as JSON text columns (WP-22 posture).
|
||||
/// </summary>
|
||||
public sealed class OrgTemplateEntity
|
||||
{
|
||||
public required string SubOrgId { get; init; }
|
||||
public OrgTemplateDto Draft { get; set; } = null!;
|
||||
public int PublishedVersion { get; set; }
|
||||
public List<OrgTemplateVersionDto> History { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>ponytail: one global lock, same shape as BriefStore — SQLite tolerates
|
||||
/// only one writer at a time anyway.</summary>
|
||||
public static class OrgTemplateStore
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static IReadOnlyList<SubOrgSummaryDto> List()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
return db.OrgTemplates.AsEnumerable()
|
||||
.Select(e => new SubOrgSummaryDto(e.SubOrgId, Published(e).OrgName, e.PublishedVersion))
|
||||
.OrderBy(s => s.SubOrgId)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static OrgTemplateAdminViewDto? AdminView(string subOrgId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||
return e is null ? null : ToAdminView(db, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the draft. Caller validates first (OrgTemplateRules); null = unknown sub-org.
|
||||
public static OrgTemplateAdminViewDto? SaveDraft(string subOrgId, OrgTemplateDto draft)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||
if (e is null) return null;
|
||||
// Normalize identity fields the client can't be trusted with: the row key and
|
||||
// the draft marker (Version 0) always win over whatever was posted.
|
||||
e.Draft = draft with { SubOrgId = subOrgId, Version = 0 };
|
||||
db.SaveChanges();
|
||||
return ToAdminView(db, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft → published: append a snapshot to history, advance the pointer. Returns
|
||||
/// the new version + how many unsent briefs of this sub-org it re-themes.
|
||||
public static PublishOrgTemplateResponse? Publish(string subOrgId, string at)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||
if (e is null) return null;
|
||||
var version = e.PublishedVersion + 1;
|
||||
// Reassign (not mutate) the list: the JSON ValueConverter has no ValueComparer,
|
||||
// so EF only sees the change when the property reference changes.
|
||||
e.History = new List<OrgTemplateVersionDto>(e.History)
|
||||
{
|
||||
new(version, at, e.Draft with { Version = version }),
|
||||
};
|
||||
e.PublishedVersion = version;
|
||||
db.SaveChanges();
|
||||
return new PublishOrgTemplateResponse(version, CountUnsent(db, subOrgId));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rollback = copy an old published snapshot into the draft (admin republishes to
|
||||
/// make it live). History stays append-only. Null = unknown sub-org or version.
|
||||
public static OrgTemplateAdminViewDto? Rollback(string subOrgId, int version)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||
var old = e?.History.FirstOrDefault(h => h.Version == version);
|
||||
if (e is null || old is null) return null;
|
||||
e.Draft = old.Template with { Version = 0 };
|
||||
db.SaveChanges();
|
||||
return ToAdminView(db, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The template a brief renders with: the pinned version once sent (immutable
|
||||
/// letters), else the sub-org's current published version.
|
||||
public static OrgTemplateDto TemplateForBrief(string subOrgId, int? pinnedVersion)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
// ponytail: briefs from before this WP carry an empty SubOrgId — fall back to
|
||||
// the first seeded sub-org instead of failing the whole screen.
|
||||
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
|
||||
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
|
||||
var pinned = pinnedVersion is { } v ? e.History.FirstOrDefault(h => h.Version == v)?.Template : null;
|
||||
return pinned ?? Published(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static int PublishedVersionOf(string subOrgId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Seeded();
|
||||
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
|
||||
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
|
||||
return e.PublishedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test seam: drop all rows so the next call re-seeds.
|
||||
public static void Reset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
db.OrgTemplates.RemoveRange(db.OrgTemplates);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private static OrgTemplateDto Published(OrgTemplateEntity e) =>
|
||||
e.History.First(h => h.Version == e.PublishedVersion).Template;
|
||||
|
||||
private static OrgTemplateAdminViewDto ToAdminView(AppDbContext db, OrgTemplateEntity e) =>
|
||||
new(e.Draft, e.PublishedVersion, e.History, CountUnsent(db, e.SubOrgId));
|
||||
|
||||
// Status is a JSON column (not translatable to SQL) — count in memory; the demo
|
||||
// holds a handful of briefs at most.
|
||||
private static int CountUnsent(AppDbContext db, string subOrgId) =>
|
||||
db.Briefs.AsEnumerable().Count(b => b.SubOrgId == subOrgId && b.Status.Tag != "sent");
|
||||
|
||||
private static AppDbContext Seeded()
|
||||
{
|
||||
var db = Db.Create();
|
||||
if (!db.OrgTemplates.Any())
|
||||
{
|
||||
db.OrgTemplates.AddRange(OrgTemplateSeed.All());
|
||||
db.SaveChanges();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two seeded sub-organizations so the admin editor can demonstrate isolation
|
||||
/// (editing one never touches the other). Values come from the fictitious sample
|
||||
/// artifact `voorbeeldbrief-inschrijving.pdf`; each starts with draft == published v1.
|
||||
/// </summary>
|
||||
public static class OrgTemplateSeed
|
||||
{
|
||||
public const string Registers = "cibg-registers";
|
||||
public const string Vakbekwaamheid = "cibg-vakbekwaamheid";
|
||||
|
||||
// Deterministic seed timestamp (stable golden files, stable demos).
|
||||
private const string SeededAt = "2026-07-01T00:00:00.0000000+00:00";
|
||||
|
||||
public static IEnumerable<OrgTemplateEntity> All() => new[]
|
||||
{
|
||||
Entity(new OrgTemplateDto(
|
||||
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))),
|
||||
Entity(new OrgTemplateDto(
|
||||
Vakbekwaamheid, "CIBG Vakbekwaamheid",
|
||||
"Retouradres: Postbus 11111, 2500 BB Den Haag",
|
||||
LogoDocumentId: null,
|
||||
"CIBG Vakbekwaamheid · Postbus 11111, 2500 BB Den Haag · 070 111 11 11 · vakbekwaamheid@voorbeeld.example",
|
||||
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
|
||||
"M. Bakker", "Hoofd Vakbekwaamheid, CIBG", "Met vriendelijke groet,",
|
||||
new MarginsDto(25, 20, 20, 25))),
|
||||
};
|
||||
|
||||
private static OrgTemplateEntity Entity(OrgTemplateDto v1) => new()
|
||||
{
|
||||
SubOrgId = v1.SubOrgId,
|
||||
Draft = v1 with { Version = 0 },
|
||||
PublishedVersion = 1,
|
||||
History = new() { new OrgTemplateVersionDto(1, SeededAt, v1 with { Version = 1 }) },
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using BigRegister.Api.Data;
|
||||
|
||||
namespace BigRegister.Domain.Authorization;
|
||||
|
||||
public enum PrincipalRole { Drafter, Approver }
|
||||
public enum PrincipalRole { Drafter, Approver, Admin }
|
||||
|
||||
/// <summary>
|
||||
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
|
||||
@@ -24,30 +24,46 @@ public enum BriefAction { Approve, Reject, Send }
|
||||
/// </summary>
|
||||
public static class Authz
|
||||
{
|
||||
public static Principal ResolvePrincipal(HttpContext ctx) =>
|
||||
new(ctx.Request.Headers["X-Role"].ToString() == "approver" ? PrincipalRole.Approver : PrincipalRole.Drafter);
|
||||
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch
|
||||
{
|
||||
"approver" => PrincipalRole.Approver,
|
||||
"admin" => PrincipalRole.Admin,
|
||||
_ => PrincipalRole.Drafter,
|
||||
});
|
||||
|
||||
public static string ActingId(Principal principal) =>
|
||||
principal.Role == PrincipalRole.Approver ? BriefStore.ApproverId : BriefStore.DrafterId;
|
||||
public static string ActingId(Principal principal) => principal.Role switch
|
||||
{
|
||||
PrincipalRole.Approver => BriefStore.ApproverId,
|
||||
PrincipalRole.Admin => BriefStore.AdminId,
|
||||
_ => BriefStore.DrafterId,
|
||||
};
|
||||
|
||||
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
|
||||
/// tied to any specific brief's live status; contrast Decisions below).
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) =>
|
||||
principal.Role == PrincipalRole.Approver
|
||||
? new[] { "brief:approve", "brief:reject", "brief:send" }
|
||||
: Array.Empty<string>();
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||
{
|
||||
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||
PrincipalRole.Admin => new[] { "orgtemplate:edit" },
|
||||
_ => Array.Empty<string>(),
|
||||
};
|
||||
|
||||
/// Role + four-eyes (SoD) check only — no status. This is the exact check
|
||||
/// BriefStore.Review enforces before its status guard; kept separate from
|
||||
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
|
||||
/// today's behavior exactly.
|
||||
/// today's behavior exactly. The explicit Approver condition keeps the new Admin
|
||||
/// role out of the review flow (WP-23) — SoD alone would have let it through.
|
||||
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
|
||||
{
|
||||
BriefAction.Approve or BriefAction.Reject => ActingId(principal) != drafterId,
|
||||
BriefAction.Approve or BriefAction.Reject =>
|
||||
principal.Role == PrincipalRole.Approver && ActingId(principal) != drafterId,
|
||||
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Org-template management (WP-23): admin-only, resource-independent — templates
|
||||
/// have no per-resource state to weigh, so role IS the whole decision here.
|
||||
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
/// Resource-aware decision for the screen DTO: "would this action succeed right
|
||||
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
|
||||
/// it never re-derives these booleans itself.
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class DocumentRules
|
||||
{
|
||||
private static readonly string[] Pdf = { "application/pdf" };
|
||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
||||
private static readonly string[] Image = { "image/jpeg", "image/png" };
|
||||
|
||||
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||
@@ -42,6 +43,13 @@ public static class DocumentRules
|
||||
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
||||
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
||||
},
|
||||
// WP-23: the admin's org-template logo rides the same upload machinery as the
|
||||
// wizard documents — one category under its own "wizard" id.
|
||||
"org-template" => new[]
|
||||
{
|
||||
new DocumentCategory("org-logo", "Organisatielogo",
|
||||
"Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
|
||||
},
|
||||
_ => Array.Empty<DocumentCategory>(),
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
|
||||
namespace BigRegister.Domain.Letters;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED org-template validation (Brief v2 PRD §5): bounded margins so an
|
||||
/// admin cannot break the letter geometry, and the identity fields a letter cannot
|
||||
/// render without. The FE mirrors the bounds for instant feedback (config values on
|
||||
/// the wire if ever needed); this is the authority.
|
||||
/// </summary>
|
||||
public static class OrgTemplateRules
|
||||
{
|
||||
public const int MarginMinMm = 10;
|
||||
public const int MarginMaxMm = 50;
|
||||
|
||||
/// Returns a rejection reason, or null when the draft is acceptable.
|
||||
public static string? RejectDraft(OrgTemplateDto draft)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft.OrgName)) return "Organisatienaam is verplicht.";
|
||||
if (string.IsNullOrWhiteSpace(draft.SignatureName)) return "Naam van de ondertekenaar is verplicht.";
|
||||
var m = draft.Margins;
|
||||
var outOfBounds = new[] { m.TopMm, m.RightMm, m.BottomMm, m.LeftMm }
|
||||
.Any(v => v is < MarginMinMm or > MarginMaxMm);
|
||||
return outOfBounds
|
||||
? $"Marges moeten tussen {MarginMinMm} en {MarginMaxMm} mm liggen."
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Letters;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -345,6 +346,31 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||
return Results.Content(archived, "text/html");
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||
// brief, so the appearance can be checked before publishing touches real letters.
|
||||
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||
if (view is null) return Results.NotFound();
|
||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||
}))
|
||||
.ExcludeFromDescription();
|
||||
|
||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
@@ -354,16 +380,77 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
.WithName("briefReset")
|
||||
.Produces<BriefViewDto>();
|
||||
|
||||
// --- Organization templates (WP-23): the second template axis — appearance and
|
||||
// identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam
|
||||
// as drafter/approver); the same Authz check gates every endpoint and feeds the
|
||||
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
|
||||
|
||||
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
Results.Ok(OrgTemplateStore.List())))
|
||||
.WithName("orgTemplates")
|
||||
.Produces<List<SubOrgSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||
.WithName("orgTemplateGET")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var reject = OrgTemplateRules.RejectDraft(req.Draft);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
|
||||
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
|
||||
}))
|
||||
.WithName("orgTemplatePUT")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var r = OrgTemplateStore.Publish(subOrgId, Now());
|
||||
if (r is not null)
|
||||
app.Logger.LogInformation("orgtemplate publish subOrg={SubOrg} version={Version} affected={Affected}",
|
||||
subOrgId, r.Version, r.AffectedUnsentBriefs);
|
||||
return r is not null ? Results.Ok(r) : Results.NotFound();
|
||||
}))
|
||||
.WithName("orgTemplatePublish")
|
||||
.Produces<PublishOrgTemplateResponse>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||
.WithName("orgTemplateRollback")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||
|
||||
// One gate for every org-template endpoint — the enforce twin of the
|
||||
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
|
||||
static IResult OrgAdmin(HttpContext ctx, Func<IResult> action) =>
|
||||
Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx))
|
||||
? action()
|
||||
: Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||
|
||||
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
e.ToDto(),
|
||||
BriefSeed.PassagesFor(e.Beroep),
|
||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId));
|
||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
|
||||
// Sent letters render with the version pinned at send; everything else follows
|
||||
// the sub-org's current published template (WP-23 immutability invariant).
|
||||
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null));
|
||||
|
||||
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
||||
|
||||
@@ -902,6 +902,238 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplates",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SubOrgSummaryDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-template/{subOrgId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplateGET",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplatePUT",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SaveOrgTemplateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-template/{subOrgId}/publish": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplatePublish",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublishOrgTemplateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-template/{subOrgId}/rollback/{version}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplateRollback",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1163,6 +1395,9 @@
|
||||
},
|
||||
"decisions": {
|
||||
"$ref": "#/components/schemas/BriefDecisionsDto"
|
||||
},
|
||||
"orgTemplate": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1509,6 +1744,28 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MarginsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"rightMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"bottomMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"leftMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1522,6 +1779,96 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OrgTemplateAdminViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
},
|
||||
"publishedVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OrgTemplateVersionDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"unsentBriefs": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OrgTemplateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subOrgId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"orgName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"returnAddress": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"logoDocumentId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"footerContact": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"footerLegal": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signatureName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signatureRole": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signatureClosing": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"margins": {
|
||||
"$ref": "#/components/schemas/MarginsDto"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OrgTemplateVersionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"publishedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"template": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ParagraphDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1626,6 +1973,20 @@
|
||||
},
|
||||
"additionalProperties": { }
|
||||
},
|
||||
"PublishOrgTemplateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"affectedUnsentBriefs": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferentieResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1769,6 +2130,33 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SaveOrgTemplateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubOrgSummaryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subOrgId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"orgName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"publishedVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubmitApplicationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -23,4 +23,10 @@
|
||||
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="LetterHtml.golden.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</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));
|
||||
}
|
||||
}
|
||||
180
backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
Normal file
180
backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
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>
|
||||
/// Org templates (WP-23): admin-only endpoints, draft→publish versioning, and the
|
||||
/// sent-brief immutability invariant (pin at send, republish touches unsent only).
|
||||
/// Same reset discipline as BriefEndpointTests — the stores are process-global.
|
||||
/// </summary>
|
||||
public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private const string Registers = OrgTemplateSeed.Registers;
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path);
|
||||
if (role is not null) req.Headers.Add("X-Role", role);
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
private async Task<OrgTemplateAdminViewDto> AdminView(string subOrgId = Registers)
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{subOrgId}", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
return (await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!;
|
||||
}
|
||||
|
||||
private static void ResetStores()
|
||||
{
|
||||
OrgTemplateStore.Reset();
|
||||
BriefStore.Reset();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_endpoints_are_admin_only()
|
||||
{
|
||||
ResetStores();
|
||||
// drafter (no header) and approver both bounce off every admin endpoint.
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await _client.GetAsync("/api/v1/admin/org-templates")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "approver"))).StatusCode);
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/admin/org-templates", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var list = await res.Content.ReadFromJsonAsync<List<SubOrgSummaryDto>>();
|
||||
Assert.Equal(new[] { Registers, OrgTemplateSeed.Vakbekwaamheid }, list!.Select(s => s.SubOrgId));
|
||||
Assert.All(list!, s => Assert.Equal(1, s.PublishedVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_increments_version_appends_history_and_counts_unsent_briefs()
|
||||
{
|
||||
ResetStores();
|
||||
// One unsent brief for this sub-org (GetOrCreate on first read).
|
||||
await _client.GetAsync("/api/v1/brief");
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
|
||||
Assert.Equal(2, published!.Version);
|
||||
Assert.Equal(1, published.AffectedUnsentBriefs);
|
||||
|
||||
var view = await AdminView();
|
||||
Assert.Equal(2, view.PublishedVersion);
|
||||
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version));
|
||||
Assert.Equal(1, view.UnsentBriefs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_draft_validates_margins_and_round_trips()
|
||||
{
|
||||
ResetStores();
|
||||
var draft = (await AdminView()).Draft;
|
||||
|
||||
var invalid = draft with { Margins = new MarginsDto(5, 20, 20, 25) };
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync(
|
||||
Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(invalid)))).StatusCode);
|
||||
|
||||
var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) };
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(valid)));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
||||
Assert.Equal("BIG-register (nieuw)", view!.Draft.OrgName);
|
||||
Assert.Equal(30, view.Draft.Margins.TopMm);
|
||||
// Saving a draft publishes nothing.
|
||||
Assert.Equal(1, view.PublishedVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollback_copies_an_old_version_into_the_draft_without_rewriting_history()
|
||||
{
|
||||
ResetStores();
|
||||
var draft = (await AdminView()).Draft;
|
||||
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(draft with { OrgName = "Versie twee" })));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/1", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
||||
Assert.Equal("BIG-register", view!.Draft.OrgName); // v1 content back in the draft
|
||||
Assert.Equal(2, view.PublishedVersion); // still live: v2 (rollback ≠ publish)
|
||||
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); // append-only, untouched
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.SendAsync(
|
||||
Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sent_brief_keeps_its_pinned_template_while_an_unsent_brief_follows_a_republish()
|
||||
{
|
||||
ResetStores();
|
||||
// Walk one brief to sent under template v1.
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
var filled = brief.Sections
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||
s.Required && s.Blocks.Count == 0
|
||||
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
||||
: s.Blocks))
|
||||
.ToList();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
|
||||
var sent = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal(1, sent!.OrgTemplate.Version);
|
||||
|
||||
// Republish with a new org name.
|
||||
var draft = (await AdminView()).Draft;
|
||||
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: 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 still renders v1 with the old name (immutable)...
|
||||
var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
Assert.Equal(1, sentView!.OrgTemplate.Version);
|
||||
Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName);
|
||||
|
||||
// ...while a fresh (unsent) brief follows the new published version.
|
||||
var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal(2, freshView!.OrgTemplate.Version);
|
||||
Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_returns_the_orgtemplate_capability_for_admin()
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
||||
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||
Assert.Equal(new[] { "orgtemplate:edit" }, me!.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_cannot_slip_into_the_brief_review_flow()
|
||||
{
|
||||
ResetStores();
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
var filled = brief.Sections
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||
s.Required && s.Blocks.Count == 0
|
||||
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
||||
: s.Blocks))
|
||||
.ToList();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||
|
||||
// Approve/reject require the Approver role explicitly — admin passes SoD
|
||||
// (different identity) but must still be Forbidden.
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "admin"))).StatusCode);
|
||||
}
|
||||
}
|
||||
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
|
||||
- api-bin:/src/src/BigRegister.Api/bin
|
||||
- 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:
|
||||
- '5000:5000'
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ From WP-01 onward, additionally:
|
||||
npm run test-storybook:ci
|
||||
```
|
||||
|
||||
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
||||
only needs re-running if a WP unexpectedly touches `backend/`.
|
||||
Phases 0–5 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
|
||||
WPs `cd backend && dotnet test` is part of GREEN, and any wire change ends with
|
||||
`npm run gen:api` leaving no drift.
|
||||
|
||||
From WP-19 onward, `npm run e2e` is part of CI (its own job) but NOT part of the local
|
||||
GREEN one-liner above — it needs the real backend + `npm start` already running (see
|
||||
@@ -63,9 +64,15 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |
|
||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | todo |
|
||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | todo |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | 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-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 | done |
|
||||
| [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 |
|
||||
|
||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||
@@ -75,6 +82,9 @@ are independent of each other and of phases 1–4 — pick any order; **18 is th
|
||||
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the
|
||||
FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the
|
||||
current in-memory persistence is a documented, defensible POC choice, not a bug.
|
||||
Phase 6 (Brief v2, the "Brief opstellen v2" PRD) is strictly ordered
|
||||
23 → 24 → 25 → 26 → 27 → 28: 24 needs 23's `orgTemplate` on the wire, 25 needs 24's
|
||||
`letter.css` contract, 26 needs 23's endpoints + 24's canvas, 27/28 polish on top.
|
||||
|
||||
## WP template
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-20 — Second locale proof
|
||||
|
||||
Status: done (pending commit)
|
||||
Status: done (e276629)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
||||
|
||||
Status: done (pending commit)
|
||||
Status: done (40dbcb2)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-22 — Durable persistence (optional tier)
|
||||
|
||||
Status: done (pending commit)
|
||||
Status: done (556f2f4)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
114
docs/backlog/WP-23-org-template-backend.md
Normal file
114
docs/backlog/WP-23-org-template-backend.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# WP-23 — Org-template backend + admin role
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
PRD "Brief opstellen v2" splits a rendered letter over two orthogonal template axes:
|
||||
the **case-type template** (section structure + placeholders — exists, unchanged) and
|
||||
a new **organization template** (appearance/identity per sub-organization: letterhead,
|
||||
footer, signature, margins). This WP builds the second axis server-side plus the
|
||||
`admin` role that will edit it (WP-26). Everything downstream (canvas WP-24, preview
|
||||
WP-25, editor WP-26) reads what this WP serves.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`)
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source)
|
||||
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22)
|
||||
- `docs/backlog/WP-18-abac-capability-spine.md` (how the capability spine works)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **No case model, no sub-org auth scoping.** `GET /brief` stays; the brief just
|
||||
gains a `SubOrgId`. Two seeded sub-orgs (`cibg-registers`, `cibg-vakbekwaamheid`)
|
||||
exist purely so the admin editor can demo isolation.
|
||||
- **One row per sub-org**, versions as a JSON history column
|
||||
(`OrgTemplateEntity { SubOrgId PK, Draft json, PublishedVersion, History json }`) —
|
||||
the WP-22 JSON-column precedent; no extra tables. Publish = append draft snapshot
|
||||
to history + version++. Rollback = copy `History[v]` into `Draft` (history stays
|
||||
append-only; admin republishes).
|
||||
- **Sent letters are immutable**: `Send` pins `SentOrgTemplateVersion`; a sent
|
||||
brief's `BriefViewDto.orgTemplate` resolves from history, never from the current
|
||||
published version. Unsent briefs always follow the current published version —
|
||||
that is the point of the admin editor.
|
||||
- **Admin = third `X-Role` value** (`PrincipalRole.Admin`), capability
|
||||
`orgtemplate:edit` via `GET /me`. Approve/reject gain an explicit
|
||||
`Role == Approver` condition so the new role cannot slip through the SoD-only
|
||||
check. The existing `X-Admin` document-deletion seam stays untouched.
|
||||
- **Trimmed model** (PRD §3 minus): no signature image asset, no structured address
|
||||
objects, no per-template fonts. Return address / footer contact are multiline
|
||||
strings. Margins are 4 bounded ints (mm, 10–50) — server-validated.
|
||||
- **Logo = existing upload machinery**: seed an `org-logo` category (png/jpeg, 1 MB)
|
||||
under a `org-template` wizardId; the template stores only `logoDocumentId`.
|
||||
- Seed values come from the sample artifact `voorbeeldbrief-inschrijving.pdf`
|
||||
(A. de Vries / Hoofd Registratie / Postbus 00000 / info@voorbeeld.example — all fictitious).
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — `MarginsDto`, `OrgTemplateDto`,
|
||||
`OrgTemplateVersionDto`, `OrgTemplateAdminViewDto`, `SaveOrgTemplateRequest`,
|
||||
`PublishOrgTemplateResponse`, `SubOrgSummaryDto`; `BriefViewDto` + `OrgTemplate`.
|
||||
- `backend/src/BigRegister.Api/Data/OrgTemplateStore.cs` (new) — entity + store + seed.
|
||||
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` — OrgTemplates DbSet + JSON converters.
|
||||
- `backend/src/BigRegister.Api/Data/Migrations/*` — new migration.
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `SubOrgId`, `SentOrgTemplateVersion`, pin at `Send`.
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` — `Admin` role, capability, gate.
|
||||
- `backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs` — `org-logo` category.
|
||||
- `backend/src/BigRegister.Api/Program.cs` — 5 admin endpoints, `ToView` orgTemplate resolution.
|
||||
- `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` (new).
|
||||
- Regenerated: `backend/swagger.json`, `src/app/shared/infrastructure/api-client.ts`.
|
||||
- FE seam only: `src/app/shared/domain/role.ts`, `shared/domain/capability.ts`,
|
||||
`shared/infrastructure/role.ts`, `shared/infrastructure/role.interceptor.ts`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. DTOs (above).
|
||||
2. `OrgTemplateEntity` + `OrgTemplateStore` (list/get/saveDraft/publish/rollback/
|
||||
published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs,
|
||||
draft == published v1) + AppDbContext mapping + migration.
|
||||
3. `PrincipalRole.Admin`; `ResolvePrincipal` reads `admin`; `RoleCapabilities(Admin)`
|
||||
→ `orgtemplate:edit`; approve/reject checks require `Approver` explicitly.
|
||||
4. Endpoints: `GET /admin/org-templates`, `GET|PUT /admin/org-template/{subOrgId}`,
|
||||
`POST …/publish` (returns impact count = unsent briefs of that sub-org),
|
||||
`POST …/rollback/{version}`. All 403 for non-admin via `Authz`.
|
||||
5. `BriefEntity.SubOrgId` (seed `cibg-registers`) + `SentOrgTemplateVersion`; `Send`
|
||||
pins; `ToView` resolves published-vs-pinned into `BriefViewDto.orgTemplate`.
|
||||
6. Seed `org-logo` upload category.
|
||||
7. `npm run gen:api`.
|
||||
8. FE: widen `Role`/`Capability` unions, `currentRole()`, interceptor URL filter
|
||||
(nothing consumes them yet — WP-24/26 do).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Publish increments `publishedVersion` and appends to history; rollback copies an
|
||||
old version into the draft without rewriting history.
|
||||
- [x] Every admin endpoint returns 403 for drafter/approver, 200 for `X-Role: admin`.
|
||||
- [x] A sent brief keeps its pinned org-template version after a republish; an unsent
|
||||
brief follows the new published version (both asserted in one test).
|
||||
- [x] Publish impact count = number of unsent briefs of that sub-org.
|
||||
- [x] `PUT` with out-of-bounds margins → 400.
|
||||
- [x] `GET /brief` carries `orgTemplate`; existing brief tests stay green.
|
||||
- [x] `GET /me` with `X-Role: admin` → `["orgtemplate:edit"]`.
|
||||
- [x] Full GREEN (FE untouched functionally, but lint/test/build/storybook all pass).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`; GREEN one-liner; curl smoke: admin list/save/publish
|
||||
(200) vs drafter (403); sent-brief pin walk-through per acceptance.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The canvas (WP-24), HTML preview endpoints + archive-at-send (WP-25), the admin UI
|
||||
(WP-26). Template approval chains (draft→publish is enough for the POC; flagged as
|
||||
an open question in the PRD). Sub-org-scoped brief authorization.
|
||||
|
||||
## Risks
|
||||
|
||||
`BriefViewDto` gains a field — additive, but the FE `parseBriefView` boundary and
|
||||
generated client must be regenerated in the same WP to keep the drift check green.
|
||||
Adding `PrincipalRole.Admin` touches the approve/reject SoD path: the explicit
|
||||
`Role == Approver` condition must preserve today's Forbidden-before-Conflict order
|
||||
(existing tests prove it).
|
||||
99
docs/backlog/WP-24-letter-canvas.md
Normal file
99
docs/backlog/WP-24-letter-canvas.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# WP-24 — Letter canvas (edit on the letter)
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
The drafter should compose **on the letter** — letterhead above, footer/signature
|
||||
below, content blocks edited in place — instead of in an abstract form next to a
|
||||
separate preview. PRD Brief v2 §4. This is a **presentation rebuild only**: the
|
||||
domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf`
|
||||
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot)
|
||||
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in)
|
||||
- `docs/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **One stylesheet is the FE⇄BE rendering contract**: `public/letter.css` — class
|
||||
vocabulary `.letter`, `.letter__letterhead`, `.letter__body`, `.letter__signature`,
|
||||
`.letter__footer`, `.letter__page-break`; margins as `--letter-margin-*` custom
|
||||
props; `@page`/print rules. Loaded via `<link>` in `index.html` (app + Storybook).
|
||||
WP-25's backend renderer inlines the same file; its parity test is the fence.
|
||||
- **`LetterCanvasComponent`** (new organism, `Domein/Brief/Letter Canvas`) with
|
||||
`editableRegions: 'content' | 'template' | 'none'` — one component serves drafter
|
||||
composing, approver review (and in WP-26, the admin editor). Letter typography on
|
||||
the canvas is the letter's, not the portal UI's — but still token-bridged.
|
||||
- `'content'` mode hosts the **existing** `letter-section`/`letter-block` components
|
||||
unchanged; letterhead/footer/signature render read-only with a subtle tint and a
|
||||
first-use caption ("komt uit de huisstijl van de organisatie").
|
||||
- `'none'` mode absorbs `letter-preview`'s rendering (paragraph grouping, placeholder
|
||||
chips, sample-values toggle); **`ui/letter-preview/` is then deleted** — the PRD
|
||||
explicitly supersedes it; no third rendering is maintained.
|
||||
- **Page-break indicator is approximate by design**: a dashed line per A4-content
|
||||
interval with the caption "±pagina-einde — afdrukvoorbeeld is leidend" (PRD §2b
|
||||
honesty requirement).
|
||||
- `brief.machine.ts` and `BriefMsg` are untouched — `git diff` must prove it.
|
||||
|
||||
## Files
|
||||
|
||||
- `public/letter.css` (new), `src/index.html` (link)
|
||||
- `src/app/brief/domain/org-template.ts` (new, pure) — `OrgTemplate` type
|
||||
- `src/app/brief/infrastructure/brief.adapter.ts` (+spec) — parse `orgTemplate`
|
||||
- `src/app/brief/ui/letter-canvas/*` (new: component + stories)
|
||||
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (pivot swap) + stories
|
||||
- `src/app/brief/ui/brief.page.ts` (pass orgTemplate through)
|
||||
- delete `src/app/brief/ui/letter-preview/*`
|
||||
|
||||
## Steps
|
||||
|
||||
1. `letter.css` from the sample PDF's geometry (A4 proportions, letterhead, address
|
||||
window + reference block, footer rule, signature).
|
||||
2. `OrgTemplate` domain type + `parseOrgTemplate` boundary in the adapter (+spec).
|
||||
3. Canvas organism: three modes, zoom input, page-break indicator.
|
||||
4. Migrate `letter-preview` rendering into `'none'` mode; delete the component,
|
||||
fold its stories into the canvas stories.
|
||||
5. Swap the composer's `@if (canEdit())` pivot for
|
||||
`<app-letter-canvas [editableRegions]="canEdit() ? 'content' : 'none'">`.
|
||||
6. Stories: three modes + zoom + page-break, all axe-gated.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Drafter edits blocks in place on the letter surface; passage picker and
|
||||
diagnostics click-to-locate still work on the canvas.
|
||||
- [x] Approver sees the identical surface read-only with the action bar.
|
||||
- [x] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
|
||||
- [x] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
|
||||
- [x] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
|
||||
- [x] Full GREEN + e2e smoke.
|
||||
|
||||
Field notes (landed alongside): the CIBG huisstijl styles bare `<header>`/`<footer>`
|
||||
elements (robijn background), so the canvas regions are `<div>`s — the letter surface
|
||||
must stay letter.css-only. The passage picker's checkboxes now get unique
|
||||
`checkboxId`s (all previously resolved to `id="undefined"`, so labels toggled only
|
||||
the first box — multi-select was broken) and an opaque background; `.letter__body`
|
||||
stacks above the page-break marks so the dashed line never draws through content,
|
||||
while the "±pagina-einde" caption floats above everything for legibility.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN one-liner; `npm run e2e` (brief flow); manual: `?role=drafter` compose on
|
||||
canvas, `?role=approver` review; `?scenario=slow|error` still degrade gracefully
|
||||
through `<app-async>`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Server-rendered preview + `[NOG IN TE VULLEN]` resolution (WP-25); admin `'template'`
|
||||
mode wiring beyond the input existing (WP-26 gives it a consumer); zoom controls
|
||||
polish + standaardbrief + diff badges (WP-27).
|
||||
|
||||
## Risks
|
||||
|
||||
The canvas duplicates the letter markup that WP-25's backend renderer will emit —
|
||||
acceptable *only* because `letter.css` is shared and WP-25 adds the class-parity
|
||||
test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible.
|
||||
Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete.
|
||||
90
docs/backlog/WP-25-letter-preview-html.md
Normal file
90
docs/backlog/WP-25-letter-preview-html.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
"What you compose is what is sent" needs a server-side rendering of the letter —
|
||||
placeholders resolved, org template applied — from the same CSS contract the canvas
|
||||
uses (PRD §2b: one rendering, used twice). The preview is the artifact: at send, the
|
||||
same composition is archived with the brief, making sent letters immutable.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §2b, §8; `docs/backlog/WP-24-letter-canvas.md` (the `letter.css` contract)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house
|
||||
pattern: `.ExcludeFromDescription()` + hand-written FE fetch)
|
||||
- `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **HTML, not PDF** (user decision at plan review): no Microsoft.Playwright/Chromium
|
||||
dependency in the POC. `GET /api/v1/brief/preview` returns `text/html` — the fully
|
||||
composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual
|
||||
affordance). The endpoint is the seam where a headless-Chromium PDF render slots
|
||||
in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if
|
||||
the POC ever needs real PDF bytes`.
|
||||
- **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the
|
||||
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
|
||||
logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from
|
||||
seed/case data; unresolved manual keys render as `[NOG IN TE VULLEN: label]`
|
||||
(PRD §8) — preview is allowed with errors, only send blocks on them.
|
||||
- **Parity is tested, not hoped for**: a golden-file test snapshots the composed
|
||||
HTML; a second test asserts every `letter`-prefixed class in the golden HTML
|
||||
exists in `letter.css`. `dotnet test` never launches a browser.
|
||||
- **Archive at send**: `Send` stores the composed HTML in `BriefEntity.ArchivedHtml`
|
||||
(SQLite text column) alongside the WP-23 version pin; the preview endpoint serves
|
||||
the archive when status is `sent`, so a republish never changes a sent letter.
|
||||
- **Two endpoints, both excluded from OpenAPI** (JSON-only generated client stays
|
||||
clean): `GET /brief/preview` and `GET /admin/org-template/{subOrgId}/preview`
|
||||
(proefbrief: draft template + a fixture brief). FE consumes them via a small
|
||||
hand-written fetch (needs the `X-Role` header) → blob → object URL in a new tab.
|
||||
- Watermark: previews of unsent letters carry a `VOORBEELD` watermark (CSS), the
|
||||
archived/sent rendering never does — the PRD's open question resolved the simple way.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` (new)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `ArchivedHtml` + archive at send (+migration)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — 2 preview endpoints
|
||||
- `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` (new) + `LetterHtml.golden.html`
|
||||
- `src/app/brief/infrastructure/letter-preview.adapter.ts` (new, fetch → `Result<string, Blob>`)
|
||||
- `src/app/brief/application/brief.store.ts` — `previewLetter()` command
|
||||
- `src/app/brief/ui/letter-composer/*` — "Voorbeeld" button
|
||||
|
||||
## Steps
|
||||
|
||||
1. `LetterHtml.Render` + placeholder resolution + data-URI logo + watermark flag.
|
||||
2. Golden-file + class-parity tests.
|
||||
3. Endpoints (serve archive when sent; proefbrief renders the draft template).
|
||||
4. Archive-at-send in `BriefStore.Send` (+ migration for `ArchivedHtml`).
|
||||
5. FE adapter + store command + button (explicit action — no live re-render; PRD §8).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Preview opens the composed print-ready letter in a new tab; browser print
|
||||
shows correct margins via `@page`.
|
||||
- [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
|
||||
despite lint errors (only send blocks).
|
||||
- [x] A sent brief serves its archived HTML unchanged after an org-template republish.
|
||||
- [x] Golden + parity tests green without any browser installed.
|
||||
- [x] `swagger.json` unchanged by the two endpoints (drift check green).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`; GREEN one-liner; manual: compose → preview → print
|
||||
dialog; send → republish template → preview still the archived rendering.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Real PDF bytes / headless Chromium (the deliberate deferral — the endpoint is the
|
||||
seam). Pixel-parity testing (the shared CSS + class-parity test is the fence).
|
||||
Pagination fidelity beyond the browser's own print engine.
|
||||
|
||||
## Risks
|
||||
|
||||
`LetterHtml` reads `public/letter.css` from disk — path must resolve for `dotnet run`,
|
||||
tests, and docker (bind mount/copy); fail loudly with a clear error if missing.
|
||||
The golden file will churn whenever the letter structure changes — that is its job;
|
||||
update it deliberately, never blindly.
|
||||
118
docs/backlog/WP-26-org-template-editor.md
Normal file
118
docs/backlog/WP-26-org-template-editor.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# WP-26 — Admin org-template editor
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
The org template (WP-23) needs its editor: an admin edits, per sub-organization, the
|
||||
letter's appearance **in place on the same canvas** the drafter composes on — the
|
||||
mirror image (`editableRegions='template'`: letterhead/footer/signature editable,
|
||||
content a read-only sample). PRD Brief v2 §5, §7h.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §5, §7h; `docs/backlog/WP-23/24/25` (endpoints, canvas, proefbrief)
|
||||
- `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`)
|
||||
- `.claude/skills/form-machine` — the house form idiom this editor follows
|
||||
- `src/app/shared/ui/upload/single-upload` (logo upload reuse)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Lives in the `brief` context** (route `/brief/huisstijl`, lazy) — same bounded
|
||||
capability, no new context. Gated by `AccessStore.can('orgtemplate:edit')` with a
|
||||
denial alert (deny-by-default); no new route guard.
|
||||
- **House form-machine idiom**: `org-template.machine.ts`
|
||||
(`OrgTemplateState`/`OrgTemplateMsg`, pure `reduce` + spec) — draft fields are form
|
||||
state, not brief state. Store (`org-template.store.ts`, root singleton) does
|
||||
debounced draft save (mirror `BriefStore.scheduleSave`), publish, rollback.
|
||||
- **Publish shows impact first**: confirmation displays the WP-23 impact count
|
||||
("Dit raakt N nog niet verzonden brieven") before the POST.
|
||||
- **Version history is a list, rollback copies into draft** (WP-23 semantics) —
|
||||
no side-by-side rendered diff (deferred; field-level history list is enough here).
|
||||
- **Logo upload reuses `single-upload`** against the `org-logo` category; the canvas
|
||||
shows `<img src="/api/v1/uploads/{id}/content">`.
|
||||
- **Proefbrief** = the WP-25 admin preview endpoint; just a button.
|
||||
- Margins are bounded number inputs (server re-validates, WP-23).
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/brief/domain/org-template.machine.ts` (+spec)
|
||||
- `src/app/brief/application/org-template.store.ts`
|
||||
- `src/app/brief/infrastructure/org-template.adapter.ts` (+spec, `parseOrgTemplateAdminView`)
|
||||
- `src/app/brief/ui/org-template-editor/*` (organism + stories)
|
||||
- `src/app/brief/ui/org-template.page.ts`
|
||||
- `src/app/app.routes.ts` (route `brief/huisstijl`)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Machine (fields, `FieldEdited`/`MarginEdited`/`LogoSet`/`DraftLoaded`/save-publish
|
||||
outcome Msgs) + spec.
|
||||
2. Adapter (generated client CRUD + parse boundary) + spec.
|
||||
3. Store: load (sub-org list + selected), debounced save, publish (impact confirm),
|
||||
rollback.
|
||||
4. Editor UI: sub-org switcher, canvas in `'template'` mode with inline-editable
|
||||
regions, margins inputs, logo upload, version history + rollback, proefbrief
|
||||
button, publish bar showing live version + published-at.
|
||||
5. Route + capability gate + stories (axe).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the
|
||||
canvas, and see the canvas update live.
|
||||
- [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.
|
||||
- [x] Version history lists published versions (who is faked, when is real);
|
||||
rollback copies an old version into the draft.
|
||||
- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
|
||||
- [x] Logo upload validates type/size client-side (existing `rejectReason`) and
|
||||
renders on the canvas after upload.
|
||||
- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
|
||||
- [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
|
||||
|
||||
GREEN one-liner; manual walk: admin edits footer + margin → canvas live-updates →
|
||||
proefbrief shows draft → publish (impact count) → `?role=drafter` reload shows new
|
||||
appearance; second sub-org unaffected.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Template approval chain (four-eyes on templates — PRD open question, out for POC).
|
||||
Rendered side-by-side version diff. Soft locks. New shared overlay/modal component —
|
||||
the publish confirmation uses the existing inline confirmation pattern, not a dialog.
|
||||
|
||||
## Risks
|
||||
|
||||
The editor is the first consumer of `editableRegions='template'` — WP-24 built the
|
||||
input but nothing exercised it; budget for canvas fixes here. Debounced draft save +
|
||||
publish can race — flush the draft save before publishing (same
|
||||
`clearTimeout`+`flushSave` discipline as `BriefStore.transition`).
|
||||
93
docs/backlog/WP-27-brief-ux-layer.md
Normal file
93
docs/backlog/WP-27-brief-ux-layer.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
|
||||
|
||||
Status: todo
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
PRD Brief v2 §7: the working-day features that make the composer pleasant daily.
|
||||
Several are nearly free **because** state is one immutable value — that's the
|
||||
teaching payload: undo/redo is a shell-side snapshot list, the rejection diff is a
|
||||
pure function over two values. Say so in code comments and stories.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §7 (and the plan-review trim recorded below)
|
||||
- `src/app/brief/application/brief.store.ts` (autosave + `SaveState` already exist)
|
||||
- `src/app/brief/domain/brief.machine.ts` — the `Seed` Msg (undo/redo's restore path)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Trim agreed at plan review.** IN: undo/redo, autosave retry affordance,
|
||||
standaardbrief, passage search, canvas zoom controls, Ctrl+Z/Ctrl+Shift+Z,
|
||||
block-level rejection-diff badges. OUT (deferred, one line each in Out of scope):
|
||||
soft lock/takeover, case-context panel, 401 autosave grace, per-user usage counts,
|
||||
shortcut-overlay dialog, inline character-level text diff.
|
||||
- **Undo/redo is shell state, not machine state**: `past`/`future: Brief[]` in
|
||||
`BriefStore` (cap 50; push on `edit()`; clear `future` on a new edit); restore
|
||||
dispatches the **existing `Seed` Msg** — zero machine changes — then `scheduleSave()`.
|
||||
- **Standaardbrief**: backend seeds `IsDefault` on 2–3 kern passages
|
||||
(`LibraryPassageDto` gains the flag); one button, visible only while the kern
|
||||
section is empty, dispatches the existing `PassagesInserted` with the default set —
|
||||
one Msg, one undo step.
|
||||
- **Passage search is a client-side filter** in the picker (label + content match) —
|
||||
the library is small; no server search, no usage tracking.
|
||||
- **Rejection diff**: pure `diffBlocks(before, after): BlockDiff[]` in
|
||||
`domain/brief-diff.ts` (added/removed/changed by `blockId`); the "before" snapshot
|
||||
is captured shell-side when the `Rejected` dispatch happens (POC limit: lost on
|
||||
reload — comment it). Rendered as "gewijzigd sinds afwijzing" badges on the canvas;
|
||||
the approver gets a "Toon wijzigingen" toggle on resubmission.
|
||||
- **Autosave retry**: `SaveState.Error` already exists; add the "Opnieuw proberen"
|
||||
button that calls the existing flush path. No new state.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/brief/application/brief.store.ts` (+spec: history bounds, clear-on-edit,
|
||||
redo, rejection snapshot)
|
||||
- `src/app/brief/domain/brief-diff.ts` (new, +spec)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`IsDefault` seed) +
|
||||
`Contracts/Dtos.cs` (`LibraryPassageDto`) + gen:api + adapter parse
|
||||
- `src/app/brief/ui/passage-picker/*` (search input)
|
||||
- `src/app/brief/ui/letter-canvas/*` (diff badges, standaardbrief button, zoom controls)
|
||||
- `src/app/brief/ui/brief.page.ts` (undo/redo buttons + keydown listener, retry button)
|
||||
|
||||
## Steps
|
||||
|
||||
1. `diffBlocks` + spec (added/removed/changed/unchanged; changed = same blockId,
|
||||
different content).
|
||||
2. Store: history + undo/redo + rejection snapshot (+spec).
|
||||
3. Backend `IsDefault` + gen:api + parse.
|
||||
4. UI: standaardbrief button, search, zoom, badges, keyboard, retry.
|
||||
5. Stories for the new states (axe).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
|
||||
history capped at 50; a new edit clears redo; restore re-triggers autosave.
|
||||
- [ ] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
|
||||
step; button gone once kern is non-empty.
|
||||
- [ ] Search filters passages by label and content.
|
||||
- [ ] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
|
||||
removed blocks are badged (block granularity).
|
||||
- [ ] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
|
||||
content never lost locally.
|
||||
- [ ] Full GREEN.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN one-liner; store + diff specs; manual reject→edit→diff walk with two roles.
|
||||
|
||||
## Out of scope (deferred, per plan review)
|
||||
|
||||
Soft lock/heartbeat/takeover (real session infra, no FP teaching value here).
|
||||
Case-context panel (no case data exists). 401 autosave grace (auth is faked).
|
||||
Per-user passage usage counts (bookkeeping, demos nothing). Shortcut overlay dialog
|
||||
(no modal component exists; not worth building one). Inline character-level diff
|
||||
(block granularity carries the teaching point).
|
||||
|
||||
## Risks
|
||||
|
||||
Undo history holds `Brief` snapshots — deep-frozen immutable values, so sharing is
|
||||
safe, but never push non-content dispatches (status transitions, `Seed` itself) into
|
||||
history or undo will replay workflow state. The rejection snapshot lives in memory
|
||||
only — document it where it's captured.
|
||||
66
docs/backlog/WP-28-brief-v2-demo-polish.md
Normal file
66
docs/backlog/WP-28-brief-v2-demo-polish.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
|
||||
|
||||
Status: todo
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
Phase 6 ships across five WPs; this one makes it demonstrable and closes the loop:
|
||||
a demo script that maps every kept PRD §12 scenario to a URL + click path, an e2e
|
||||
spec covering the new flows end-to-end, story gap-fill, and the docs/README updates
|
||||
that keep CLAUDE.md and the backlog truthful.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §6 (demo choreography), §12 (scenario list); WP-23..27 as built
|
||||
- `e2e/` (WP-19 conventions); `src/app/shared/infrastructure/scenario.ts`
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **No preset registry.** The PRD's 18 scenarios collapse onto the existing toggles:
|
||||
`?role=drafter|approver|admin`, `?scenario=slow|loading|error` (the interceptor
|
||||
already covers all `/api/` calls, the new endpoints included), and
|
||||
`POST /brief/reset`. The demo script documents the mapping; no new interceptor
|
||||
cases, no scenario code.
|
||||
- Demo script lives at `docs/prd/0003-brief-v2-demo-script.md` and follows the §6
|
||||
choreography (compose → preview → switch sub-org seed → "two axes, one render").
|
||||
- One e2e spec, not a suite: drafter composes on canvas → submit → approve → send
|
||||
pins the org-template version; admin publishes → drafter canvas reflects it.
|
||||
Preview assertion is content-type-level (text/html), not pixel.
|
||||
- CLAUDE.md gets the new role value + route only — keep it rules, not narrative.
|
||||
|
||||
## Files
|
||||
|
||||
- `docs/prd/0003-brief-v2-demo-script.md` (new)
|
||||
- `e2e/brief-v2.spec.ts` (new)
|
||||
- story gap-fill where WP-24..27 left holes
|
||||
- `docs/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Demo script: table scenario → URL + clicks, covering every kept §12 entry.
|
||||
2. e2e spec (backend + FE running, WP-19 pattern).
|
||||
3. Story sweep for the new components/states.
|
||||
4. Docs updates.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every kept PRD §12 scenario has a working URL + click path in the script
|
||||
(walked manually once).
|
||||
- [ ] `npm run e2e` green, including the new spec.
|
||||
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
|
||||
`?role=admin` and `/brief/huisstijl`.
|
||||
|
||||
## Verification
|
||||
|
||||
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
|
||||
`npm run e2e`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New scenario interceptor cases; a scenario-switcher UI; screenshots/video.
|
||||
|
||||
## Risks
|
||||
|
||||
The demo script rots when flows change — it lists URLs + clicks only (no prose
|
||||
walkthroughs), so churn stays cheap.
|
||||
7118
documentation.json
7118
documentation.json
File diff suppressed because one or more lines are too long
197
public/letter.css
Normal file
197
public/letter.css
Normal file
@@ -0,0 +1,197 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ export const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
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',
|
||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { Brief, BriefDecisions } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { BriefStore } from './brief.store';
|
||||
|
||||
const decisions: BriefDecisions = {
|
||||
@@ -22,7 +24,20 @@ const brief: Brief = {
|
||||
drafterId: 'u1',
|
||||
};
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions };
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'info@voorbeeld.example',
|
||||
footerLegal: 'KvK 00000000',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||
@@ -89,3 +104,43 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
unresolvedPlaceholders,
|
||||
} from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
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
|
||||
instead of a busy boolean + a nullable error sitting side by side. */
|
||||
@@ -34,6 +37,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -48,6 +52,17 @@ export class BriefStore {
|
||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||
|
||||
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
||||
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||
stays untouched by design). Set from every server view that carries it. */
|
||||
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
|
||||
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. */
|
||||
@@ -87,8 +102,12 @@ export class BriefStore {
|
||||
|
||||
async load() {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
if (r.ok) {
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
||||
@@ -125,6 +144,7 @@ export class BriefStore {
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
if (r.ok) {
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
@@ -136,6 +156,20 @@ export class BriefStore {
|
||||
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
||||
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
|
||||
// the returned status through the pure reducer's guarded transition.
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
@@ -152,6 +186,8 @@ export class BriefStore {
|
||||
}
|
||||
|
||||
private applyServerStatus(view: BriefView) {
|
||||
// `send` pins the org-template version server-side — mirror whatever came back.
|
||||
this.orgTemplate.set(view.orgTemplate);
|
||||
const { brief, decisions } = view;
|
||||
const s = brief.status;
|
||||
switch (s.tag) {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
67
src/app/brief/domain/org-template.ts
Normal file
67
src/app/brief/domain/org-template.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
|
||||
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
||||
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
||||
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
||||
* never edits it here (the admin editor is WP-26).
|
||||
*/
|
||||
|
||||
export interface Margins {
|
||||
readonly topMm: number;
|
||||
readonly rightMm: number;
|
||||
readonly bottomMm: number;
|
||||
readonly leftMm: number;
|
||||
}
|
||||
|
||||
export interface OrgTemplate {
|
||||
readonly subOrgId: string;
|
||||
readonly orgName: string;
|
||||
/** Multiline; rendered above the envelope window. */
|
||||
readonly returnAddress: string;
|
||||
readonly logoDocumentId?: string;
|
||||
/** Multiline contact block in the footer. */
|
||||
readonly footerContact: string;
|
||||
readonly footerLegal: string;
|
||||
readonly signatureName: string;
|
||||
readonly signatureRole: string;
|
||||
readonly signatureClosing: string;
|
||||
readonly margins: Margins;
|
||||
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
||||
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;
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
||||
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
|
||||
import {
|
||||
parseBrief,
|
||||
parseBriefView,
|
||||
parseNode,
|
||||
parseOrgTemplate,
|
||||
parseStatus,
|
||||
} from './brief.adapter';
|
||||
|
||||
const view: BriefViewDto = {
|
||||
brief: {
|
||||
@@ -47,6 +53,18 @@ const view: BriefViewDto = {
|
||||
},
|
||||
],
|
||||
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
||||
orgTemplate: {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||
footerLegal: 'KvK 00000000',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe('brief.adapter parse boundary', () => {
|
||||
@@ -76,6 +94,26 @@ describe('brief.adapter parse boundary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the org template and drops a null logoDocumentId', () => {
|
||||
const r = parseOrgTemplate(view.orgTemplate);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.orgName).toBe('CIBG — Registers');
|
||||
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
|
||||
expect('logoDocumentId' in r.value).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a view whose org template is missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
|
||||
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
|
||||
expect(
|
||||
parseOrgTemplate({
|
||||
...view.orgTemplate,
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a view whose decisions are missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
||||
expect(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LetterBlockDto,
|
||||
LetterSectionDto,
|
||||
LibraryPassageDto,
|
||||
OrgTemplateDto,
|
||||
PlaceholderDefDto,
|
||||
RichTextBlockDto,
|
||||
RichTextNodeDto,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
} from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
@@ -37,6 +39,7 @@ export interface BriefView {
|
||||
readonly brief: Brief;
|
||||
readonly availablePassages: LibraryPassage[];
|
||||
readonly decisions: BriefDecisions;
|
||||
readonly orgTemplate: OrgTemplate;
|
||||
}
|
||||
|
||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||
@@ -301,19 +304,64 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
|
||||
});
|
||||
}
|
||||
|
||||
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
|
||||
if (
|
||||
typeof dto?.subOrgId !== 'string' ||
|
||||
typeof dto.orgName !== 'string' ||
|
||||
typeof dto.returnAddress !== 'string' ||
|
||||
typeof dto.footerContact !== 'string' ||
|
||||
typeof dto.footerLegal !== 'string' ||
|
||||
typeof dto.signatureName !== 'string' ||
|
||||
typeof dto.signatureRole !== 'string' ||
|
||||
typeof dto.signatureClosing !== 'string' ||
|
||||
typeof dto.version !== 'number'
|
||||
) {
|
||||
return err('org-template: bad shape');
|
||||
}
|
||||
const m = dto.margins;
|
||||
if (
|
||||
typeof m?.topMm !== 'number' ||
|
||||
typeof m.rightMm !== 'number' ||
|
||||
typeof m.bottomMm !== 'number' ||
|
||||
typeof m.leftMm !== 'number'
|
||||
) {
|
||||
return err('org-template: bad margins');
|
||||
}
|
||||
return ok({
|
||||
subOrgId: dto.subOrgId,
|
||||
orgName: dto.orgName,
|
||||
returnAddress: dto.returnAddress,
|
||||
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
|
||||
footerContact: dto.footerContact,
|
||||
footerLegal: dto.footerLegal,
|
||||
signatureName: dto.signatureName,
|
||||
signatureRole: dto.signatureRole,
|
||||
signatureClosing: dto.signatureClosing,
|
||||
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
|
||||
version: dto.version,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||
if (!dto.brief) return err('brief-view: missing brief');
|
||||
const brief = parseBrief(dto.brief);
|
||||
if (!brief.ok) return brief;
|
||||
const decisions = parseDecisions(dto.decisions);
|
||||
if (!decisions.ok) return decisions;
|
||||
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
||||
if (!orgTemplate.ok) return orgTemplate;
|
||||
const availablePassages: LibraryPassage[] = [];
|
||||
for (const p of dto.availablePassages ?? []) {
|
||||
const parsed = parsePassage(p);
|
||||
if (!parsed.ok) return parsed;
|
||||
availablePassages.push(parsed.value);
|
||||
}
|
||||
return ok({ brief: brief.value, availablePassages, decisions: decisions.value });
|
||||
return ok({
|
||||
brief: brief.value,
|
||||
availablePassages,
|
||||
decisions: decisions.value,
|
||||
orgTemplate: orgTemplate.value,
|
||||
});
|
||||
}
|
||||
|
||||
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -40,28 +40,33 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (loaded(); as s) {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
/>
|
||||
@if (store.orgTemplate(); as orgTemplate) {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
|
||||
413
src/app/brief/ui/letter-canvas/letter-canvas.component.ts
Normal file
413
src/app/brief/ui/letter-canvas/letter-canvas.component.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = {
|
||||
readonly list: 'bullet' | 'number' | null;
|
||||
readonly items: readonly Paragraph[];
|
||||
};
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
for (const para of paras) {
|
||||
const kind = para.list ?? null;
|
||||
const last = out[out.length - 1];
|
||||
if (kind && last && last.list === kind) last.items.push(para);
|
||||
else out.push({ list: kind, items: [para] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
|
||||
/** Organism: the letter as one surface — the org template's letterhead, signature and
|
||||
footer around the case-type template's sections. `editableRegions` picks who edits
|
||||
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||
`'template'` reserves the org-identity regions for the admin editor (WP-26).
|
||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||
the same file the backend preview renderer inlines (WP-25). */
|
||||
@Component({
|
||||
selector: 'app-letter-canvas',
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
||||
.surface {
|
||||
background: var(--rhc-color-grijs-100);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.surface .letter {
|
||||
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. */
|
||||
.from-template {
|
||||
background: var(--rhc-color-grijs-100);
|
||||
outline: 2mm solid var(--rhc-color-grijs-100);
|
||||
}
|
||||
.from-template-caption {
|
||||
font-size: 7.5pt;
|
||||
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
|
||||
color: var(--rhc-color-grijs-700);
|
||||
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: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (editableRegions() === 'none') {
|
||||
<div class="toolbar">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="surface">
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoom()">
|
||||
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||
<div class="letter__letterhead" [class.from-template]="tintTemplate()">
|
||||
@if (tintTemplate()) {
|
||||
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
|
||||
}
|
||||
@if (logoUrl()) {
|
||||
<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>
|
||||
<dl class="reference">
|
||||
<div>
|
||||
<dt>{{ referenceLabel() }}</dt>
|
||||
<dd>{{ brief().briefId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ dateLabel() }}</dt>
|
||||
<dd>{{ letterDate }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="letter__body">
|
||||
@if (editableRegions() === 'content') {
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<app-letter-section
|
||||
[section]="section"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="!section.locked"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
} @else {
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<h3>{{ section.title }}</h3>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__signature" [class.from-template]="tintTemplate()">
|
||||
@if (editing()) {
|
||||
<input
|
||||
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 class="letter__footer" [class.from-template]="tintTemplate()">
|
||||
@if (editing()) {
|
||||
<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>
|
||||
|
||||
@for (top of pageBreaks(); track $index) {
|
||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||
<span>{{ pageBreakCaption() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LetterCanvasComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
/** Who edits what on the surface: drafter ('content'), read-only ('none'),
|
||||
admin editor ('template', consumer arrives in WP-26). */
|
||||
editableRegions = input<'content' | 'template' | 'none'>('none');
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
zoom = input(1);
|
||||
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||
logoUrl = input<string | null>(null);
|
||||
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`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
fromTemplateCaption = input(
|
||||
$localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,
|
||||
);
|
||||
pageBreakCaption = input(
|
||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
||||
);
|
||||
recipientText = input(
|
||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
||||
);
|
||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
||||
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 letterDate = formatDatumNl(new Date());
|
||||
|
||||
/** 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. */
|
||||
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(() => {
|
||||
const m = this.orgTemplate().margins;
|
||||
return {
|
||||
'--letter-margin-top': `${m.topMm}mm`,
|
||||
'--letter-margin-right': `${m.rightMm}mm`,
|
||||
'--letter-margin-bottom': `${m.bottomMm}mm`,
|
||||
'--letter-margin-left': `${m.leftMm}mm`,
|
||||
};
|
||||
});
|
||||
|
||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
for (const d of this.diagnostics()) {
|
||||
if (!d.placeholderKey) continue;
|
||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) =>
|
||||
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
|
||||
|
||||
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||
|
||||
private page = viewChild<ElementRef<HTMLElement>>('page');
|
||||
protected pageBreaks = signal<readonly number[]>([]);
|
||||
|
||||
constructor() {
|
||||
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
||||
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
||||
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
||||
this.pageBreaks.set(
|
||||
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
|
||||
);
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const el = this.page()?.nativeElement;
|
||||
if (!el) return;
|
||||
observer.observe(el);
|
||||
onCleanup(() => observer.unobserve(el));
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
154
src/app/brief/ui/letter-canvas/letter-canvas.stories.ts
Normal file
154
src/app/brief/ui/letter-canvas/letter-canvas.stories.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { LetterCanvasComponent } from './letter-canvas.component';
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example\n070 000 00 00',
|
||||
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: 1,
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Standaard toelichting',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Standaardtekst.' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Enough repeated body text to push the surface past one A4 page. */
|
||||
const longBrief: Brief = {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) =>
|
||||
s.sectionKey === 'kern'
|
||||
? {
|
||||
...s,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-long',
|
||||
content: {
|
||||
paragraphs: Array.from({ length: 40 }, (_, i) => ({
|
||||
nodes: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Alinea ${i + 1}: de beoordeling van uw aanvraag is uitgevoerd volgens de geldende regels voor herregistratie in het BIG-register.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: s,
|
||||
),
|
||||
};
|
||||
|
||||
const meta: Meta<LetterCanvasComponent> = {
|
||||
title: 'Domein/Brief/Letter Canvas',
|
||||
component: LetterCanvasComponent,
|
||||
args: {
|
||||
brief,
|
||||
orgTemplate,
|
||||
availablePassages: passages,
|
||||
placeholders: brief.placeholders,
|
||||
diagnostics: allDiagnostics(brief),
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterCanvasComponent>;
|
||||
|
||||
/** Drafter: content blocks editable in place; org-identity regions tinted read-only. */
|
||||
export const ContentMode: Story = { args: { editableRegions: 'content' } };
|
||||
|
||||
/** Approver/locked: the identical surface fully read-only, with the sample-values
|
||||
toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
||||
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
||||
|
||||
export const ReadOnlyZonderBevindingen: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [] },
|
||||
};
|
||||
|
||||
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
|
||||
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||
|
||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||
|
||||
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
|
||||
export const PageBreak: Story = {
|
||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||
};
|
||||
@@ -5,18 +5,18 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { Brief, LibraryPassage } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
import { LetterPreviewComponent } from '@brief/ui/letter-preview/letter-preview.component';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||
|
||||
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
|
||||
read-only preview (approver / locked), the diagnostics panel, and the action bar
|
||||
appropriate to status × permission. Presentational: emits edit + transition
|
||||
intents; the canEdit/canApprove/canReject/canSend inputs are server-computed
|
||||
decision flags (PRD-0002 phase P1) — this component never derives them. */
|
||||
/** Organism: the whole letter flow — status badge, the letter canvas (editable in
|
||||
place for the drafter, read-only for approver/locked), the diagnostics panel, and
|
||||
the action bar appropriate to status × permission. Presentational: emits edit +
|
||||
transition intents; the canEdit/canApprove/canReject/canSend inputs are
|
||||
server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */
|
||||
@Component({
|
||||
selector: 'app-letter-composer',
|
||||
imports: [
|
||||
@@ -24,8 +24,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
StatusBadgeComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
LetterSectionComponent,
|
||||
LetterPreviewComponent,
|
||||
LetterCanvasComponent,
|
||||
DiagnosticsPanelComponent,
|
||||
RejectionCommentsComponent,
|
||||
],
|
||||
@@ -42,9 +41,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
flex-wrap: wrap;
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.sections {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-2xl);
|
||||
.head-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.panel {
|
||||
margin-block: var(--rhc-space-max-xl);
|
||||
@@ -61,28 +61,28 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
template: `
|
||||
<div class="head">
|
||||
<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>
|
||||
|
||||
@if (status() === 'rejected') {
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
@if (canEdit()) {
|
||||
<div class="sections">
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<app-letter-section
|
||||
[section]="section"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[editable]="!section.locked"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-letter-preview [brief]="brief()" [diagnostics]="diagnostics()" />
|
||||
}
|
||||
<app-letter-canvas
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
[logoUrl]="logoUrl()"
|
||||
[editableRegions]="canEdit() ? 'content' : 'none'"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[diagnostics]="diagnostics()"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||
@@ -139,6 +139,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
})
|
||||
export class LetterComposerComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
canEdit = input(false);
|
||||
@@ -153,9 +155,11 @@ export class LetterComposerComponent {
|
||||
approve = output<void>();
|
||||
reject = output<string>();
|
||||
send = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
|
||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||
submitHint = input(
|
||||
|
||||
@@ -2,6 +2,20 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterComposerComponent } from './letter-composer.component';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||
import { allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
|
||||
const orgTemplate: 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: 1,
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
@@ -106,15 +120,16 @@ function brief(status: BriefStatus): Brief {
|
||||
const render = (b: Brief, decisions: BriefDecisions) => ({
|
||||
props: {
|
||||
brief: b,
|
||||
orgTemplate,
|
||||
availablePassages: passages,
|
||||
diagnostics: allDiagnostics(b),
|
||||
...decisions,
|
||||
canSubmit: true,
|
||||
busy: false,
|
||||
},
|
||||
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
|
||||
[canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [canSend]="canSend"
|
||||
[canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages"
|
||||
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject"
|
||||
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||
});
|
||||
|
||||
const meta: Meta<LetterComposerComponent> = {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Component, computed, input, signal } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = {
|
||||
readonly list: 'bullet' | 'number' | null;
|
||||
readonly items: readonly Paragraph[];
|
||||
};
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
for (const para of paras) {
|
||||
const kind = para.list ?? null;
|
||||
const last = out[out.length - 1];
|
||||
if (kind && last && last.list === kind) last.items.push(para);
|
||||
else out.push({ list: kind, items: [para] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
|
||||
Placeholders show as labelled chips (values are resolved server-side only at send);
|
||||
a chip flagged by the linter shows its error/warning state. The "Voorbeeld" toggle
|
||||
fills auto-resolvable placeholders with sample values to preview the sent result. */
|
||||
@Component({
|
||||
selector: 'app-letter-preview',
|
||||
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.letter {
|
||||
background: var(--rhc-color-wit);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-2xl);
|
||||
}
|
||||
section {
|
||||
margin-block-end: var(--rhc-space-max-xl);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div class="toolbar">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
<div class="letter">
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<app-heading [level]="3">{{ section.title }}</app-heading>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LetterPreviewComponent {
|
||||
brief = input.required<Brief>();
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
private today = formatDatumNl(new Date());
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
for (const d of this.diagnostics()) {
|
||||
if (!d.placeholderKey) continue;
|
||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) =>
|
||||
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { LetterPreviewComponent } from './letter-preview.component';
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'unresolved-at-send',
|
||||
placeholderKey: 'reden_besluit',
|
||||
location: { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 },
|
||||
message: '"Reden besluit" is nog niet ingevuld.',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<LetterPreviewComponent> = {
|
||||
title: 'Domein/Brief/Letter Preview',
|
||||
component: LetterPreviewComponent,
|
||||
args: { brief, diagnostics },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterPreviewComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const ZonderBevindingen: Story = { args: { diagnostics: [] } };
|
||||
@@ -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,6 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: var(--rhc-color-wit);
|
||||
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);
|
||||
@@ -35,6 +36,7 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
@for (p of passages(); track p.passageId) {
|
||||
<li>
|
||||
<app-checkbox
|
||||
[checkboxId]="'passage-' + p.passageId"
|
||||
[label]="p.label"
|
||||
[ngModel]="!!checked()[p.passageId]"
|
||||
(ngModelChange)="set(p.passageId, $event)"
|
||||
|
||||
@@ -33,4 +33,11 @@ export class AccessStore {
|
||||
const rd = this.capabilities();
|
||||
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';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
|
||||
* Server-resolved and opaque to the FE — never derived from a role client-side.
|
||||
*/
|
||||
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send';
|
||||
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* The two-person letter workflow's role: who is acting, a drafter or an approver.
|
||||
* The letter workflow's acting role: drafter or approver for the two-person
|
||||
* compose/review flow, admin for org-template management (WP-23, Brief v2).
|
||||
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
|
||||
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
|
||||
* letter-composer) depend on this type, not on how the role is obtained.
|
||||
*/
|
||||
export type Role = 'drafter' | 'approver';
|
||||
export type Role = 'drafter' | 'approver' | 'admin';
|
||||
|
||||
@@ -1279,6 +1279,257 @@ export class ApiClient {
|
||||
}
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
orgTemplates(): Promise<SubOrgSummaryDto[]> {
|
||||
let url_ = this.baseUrl + "/api/v1/admin/org-templates";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processOrgTemplates(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processOrgTemplates(response: Response): Promise<SubOrgSummaryDto[]> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubOrgSummaryDto[];
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<SubOrgSummaryDto[]>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
orgTemplateGET(subOrgId: string): Promise<OrgTemplateAdminViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}";
|
||||
if (subOrgId === undefined || subOrgId === null)
|
||||
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
|
||||
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processOrgTemplateGET(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processOrgTemplateGET(response: Response): Promise<OrgTemplateAdminViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
orgTemplatePUT(subOrgId: string, body: SaveOrgTemplateRequest): Promise<OrgTemplateAdminViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}";
|
||||
if (subOrgId === undefined || subOrgId === null)
|
||||
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
|
||||
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
const content_ = JSON.stringify(body);
|
||||
|
||||
let options_: RequestInit = {
|
||||
body: content_,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processOrgTemplatePUT(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processOrgTemplatePUT(response: Response): Promise<OrgTemplateAdminViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 400) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result400: any = null;
|
||||
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Bad Request", status, _responseText, _headers, result400);
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
orgTemplatePublish(subOrgId: string): Promise<PublishOrgTemplateResponse> {
|
||||
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}/publish";
|
||||
if (subOrgId === undefined || subOrgId === null)
|
||||
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
|
||||
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processOrgTemplatePublish(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processOrgTemplatePublish(response: Response): Promise<PublishOrgTemplateResponse> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PublishOrgTemplateResponse;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<PublishOrgTemplateResponse>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
orgTemplateRollback(subOrgId: string, version: number): Promise<OrgTemplateAdminViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}/rollback/{version}";
|
||||
if (subOrgId === undefined || subOrgId === null)
|
||||
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
|
||||
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
|
||||
if (version === undefined || version === null)
|
||||
throw new globalThis.Error("The parameter 'version' must be defined.");
|
||||
url_ = url_.replace("{version}", encodeURIComponent("" + version));
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processOrgTemplateRollback(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processOrgTemplateRollback(response: Response): Promise<OrgTemplateAdminViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 404) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("Not Found", status, _responseText, _headers);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AantekeningDto {
|
||||
@@ -1356,6 +1607,7 @@ export interface BriefViewDto {
|
||||
brief?: BriefDto;
|
||||
availablePassages?: LibraryPassageDto[] | undefined;
|
||||
decisions?: BriefDecisionsDto;
|
||||
orgTemplate?: OrgTemplateDto;
|
||||
}
|
||||
|
||||
export interface BrpAddressDto {
|
||||
@@ -1467,10 +1719,44 @@ export interface ManualDiplomaPolicyDto {
|
||||
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface MarginsDto {
|
||||
topMm?: number;
|
||||
rightMm?: number;
|
||||
bottomMm?: number;
|
||||
leftMm?: number;
|
||||
}
|
||||
|
||||
export interface MeDto {
|
||||
capabilities?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface OrgTemplateAdminViewDto {
|
||||
draft?: OrgTemplateDto;
|
||||
publishedVersion?: number;
|
||||
history?: OrgTemplateVersionDto[] | undefined;
|
||||
unsentBriefs?: number;
|
||||
}
|
||||
|
||||
export interface OrgTemplateDto {
|
||||
subOrgId?: string | undefined;
|
||||
orgName?: string | undefined;
|
||||
returnAddress?: string | undefined;
|
||||
logoDocumentId?: string | undefined;
|
||||
footerContact?: string | undefined;
|
||||
footerLegal?: string | undefined;
|
||||
signatureName?: string | undefined;
|
||||
signatureRole?: string | undefined;
|
||||
signatureClosing?: string | undefined;
|
||||
margins?: MarginsDto;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface OrgTemplateVersionDto {
|
||||
version?: number;
|
||||
publishedAt?: string | undefined;
|
||||
template?: OrgTemplateDto;
|
||||
}
|
||||
|
||||
export interface ParagraphDto {
|
||||
nodes?: RichTextNodeDto[] | undefined;
|
||||
list?: string | undefined;
|
||||
@@ -1506,6 +1792,11 @@ export interface ProblemDetails {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PublishOrgTemplateResponse {
|
||||
version?: number;
|
||||
affectedUnsentBriefs?: number;
|
||||
}
|
||||
|
||||
export interface ReferentieResponse {
|
||||
referentie?: string | undefined;
|
||||
}
|
||||
@@ -1551,6 +1842,16 @@ export interface SaveBriefRequest {
|
||||
sections?: LetterSectionDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface SaveOrgTemplateRequest {
|
||||
draft?: OrgTemplateDto;
|
||||
}
|
||||
|
||||
export interface SubOrgSummaryDto {
|
||||
subOrgId?: string | undefined;
|
||||
orgName?: string | undefined;
|
||||
publishedVersion?: number;
|
||||
}
|
||||
|
||||
export interface SubmitApplicationRequest {
|
||||
diplomaHerkomst?: string | undefined;
|
||||
uren?: number | undefined;
|
||||
|
||||
@@ -11,6 +11,13 @@ describe('parseMe (trust boundary)', () => {
|
||||
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
|
||||
});
|
||||
|
||||
it('recognizes the admin org-template capability (WP-23)', () => {
|
||||
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
|
||||
ok: true,
|
||||
value: ['orgtemplate:edit'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
const KNOWN: readonly Capability[] = ['brief:approve', 'brief:reject', 'brief:send'];
|
||||
const KNOWN: readonly Capability[] = [
|
||||
'brief:approve',
|
||||
'brief:reject',
|
||||
'brief:send',
|
||||
'orgtemplate:edit',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
|
||||
|
||||
@@ -2,11 +2,15 @@ import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { currentRole } from './role';
|
||||
|
||||
/**
|
||||
* Dev-only: stamps brief requests with the current `?role=` as an `X-Role` header so
|
||||
* the backend can enforce the drafter/approver rules. Only brief endpoints carry it;
|
||||
* everything else is untouched.
|
||||
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
|
||||
* header so the backend can enforce the drafter/approver/admin rules. Only the
|
||||
* brief, org-template and /me endpoints carry it (WP-23 widened the set — /me must
|
||||
* see the role or `AccessStore` could never learn a capability); everything else
|
||||
* is untouched.
|
||||
*/
|
||||
const ROLE_AWARE = ['/api/v1/brief', '/api/v1/admin/org-template', '/api/v1/me'];
|
||||
|
||||
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.includes('/api/v1/brief')) return next(req);
|
||||
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
|
||||
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Role } from '@shared/domain/role';
|
||||
* longer derives permission from this value itself.
|
||||
*/
|
||||
export function currentRole(): Role {
|
||||
return new URLSearchParams(window.location.search).get('role') === 'approver'
|
||||
? 'approver'
|
||||
: 'drafter';
|
||||
const role = new URLSearchParams(window.location.search).get('role');
|
||||
return role === 'approver' || role === 'admin' ? role : 'drafter';
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface XhrUploadHandle {
|
||||
/** Sentinel rejection so the caller can tell a user-cancel from a real failure. */
|
||||
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,
|
||||
* 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.
|
||||
*/
|
||||
contentUrl(documentId: string): string {
|
||||
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`;
|
||||
return uploadContentUrl(documentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
|
||||
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
|
||||
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||
<!-- The letter-rendering contract (WP-24): shared verbatim with the backend's
|
||||
HTML preview renderer (WP-25 inlines this same file) — keep it self-contained. -->
|
||||
<link rel="stylesheet" href="letter.css" />
|
||||
</head>
|
||||
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
|
||||
(without it, --ro-layout falls back to the blue default). -->
|
||||
|
||||
Reference in New Issue
Block a user