feat(fp): WP-23 — org-template backend + admin role
Second template axis (org identity: letterhead, footer, signature, margins) server-side: OrgTemplateStore with JSON version history, publish/rollback, sent-brief version pinning, admin role + capability, 5 admin endpoints, org-logo upload category. FE seam widened only (Role/Capability unions, interceptor); WP-24/26 consume it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -151,10 +151,43 @@ public sealed record BriefDto(
|
|||||||
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
|
// (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 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 SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||||
public sealed record RejectBriefRequest(string Comments);
|
public sealed record RejectBriefRequest(string Comments);
|
||||||
|
|
||||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
|
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
|
||||||
public sealed record MeDto(IReadOnlyList<string> Capabilities);
|
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<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||||
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||||
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||||
|
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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.Sections).HasConversion(Json<List<LetterSectionDto>>());
|
||||||
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
|
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
|
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public sealed class BriefEntity
|
|||||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||||
public BriefStatusDto Status { get; set; } = new("draft");
|
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; }
|
||||||
|
|
||||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||||
}
|
}
|
||||||
@@ -33,6 +37,7 @@ public static class BriefStore
|
|||||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||||
public const string DrafterId = "demo-drafter";
|
public const string DrafterId = "demo-drafter";
|
||||||
public const string ApproverId = "demo-approver";
|
public const string ApproverId = "demo-approver";
|
||||||
|
public const string AdminId = "demo-admin";
|
||||||
|
|
||||||
public enum Outcome { Ok, Forbidden, Conflict }
|
public enum Outcome { Ok, Forbidden, Conflict }
|
||||||
|
|
||||||
@@ -102,6 +107,9 @@ public static class BriefStore
|
|||||||
if (e is null) return (Outcome.Conflict, null);
|
if (e is null) return (Outcome.Conflict, null);
|
||||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
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);
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,10 +124,17 @@ namespace BigRegister.Api.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("SentOrgTemplateVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("TemplateId")
|
b.Property<string>("TemplateId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -140,6 +147,27 @@ namespace BigRegister.Api.Data.Migrations
|
|||||||
b.ToTable("Briefs");
|
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 =>
|
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("DocumentId")
|
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;
|
namespace BigRegister.Domain.Authorization;
|
||||||
|
|
||||||
public enum PrincipalRole { Drafter, Approver }
|
public enum PrincipalRole { Drafter, Approver, Admin }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
|
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
|
||||||
@@ -24,30 +24,46 @@ public enum BriefAction { Approve, Reject, Send }
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Authz
|
public static class Authz
|
||||||
{
|
{
|
||||||
public static Principal ResolvePrincipal(HttpContext ctx) =>
|
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch
|
||||||
new(ctx.Request.Headers["X-Role"].ToString() == "approver" ? PrincipalRole.Approver : PrincipalRole.Drafter);
|
{
|
||||||
|
"approver" => PrincipalRole.Approver,
|
||||||
|
"admin" => PrincipalRole.Admin,
|
||||||
|
_ => PrincipalRole.Drafter,
|
||||||
|
});
|
||||||
|
|
||||||
public static string ActingId(Principal principal) =>
|
public static string ActingId(Principal principal) => principal.Role switch
|
||||||
principal.Role == PrincipalRole.Approver ? BriefStore.ApproverId : BriefStore.DrafterId;
|
{
|
||||||
|
PrincipalRole.Approver => BriefStore.ApproverId,
|
||||||
|
PrincipalRole.Admin => BriefStore.AdminId,
|
||||||
|
_ => BriefStore.DrafterId,
|
||||||
|
};
|
||||||
|
|
||||||
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
|
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
|
||||||
/// tied to any specific brief's live status; contrast Decisions below).
|
/// tied to any specific brief's live status; contrast Decisions below).
|
||||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) =>
|
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||||
principal.Role == PrincipalRole.Approver
|
{
|
||||||
? new[] { "brief:approve", "brief:reject", "brief:send" }
|
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||||
: Array.Empty<string>();
|
PrincipalRole.Admin => new[] { "orgtemplate:edit" },
|
||||||
|
_ => Array.Empty<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
/// Role + four-eyes (SoD) check only — no status. This is the exact check
|
/// Role + four-eyes (SoD) check only — no status. This is the exact check
|
||||||
/// BriefStore.Review enforces before its status guard; kept separate from
|
/// BriefStore.Review enforces before its status guard; kept separate from
|
||||||
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
|
/// 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
|
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
|
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
|
||||||
_ => false,
|
_ => 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
|
/// 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;
|
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
|
||||||
/// it never re-derives these booleans itself.
|
/// 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[] Pdf = { "application/pdf" };
|
||||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
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",
|
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||||
@@ -42,6 +43,13 @@ public static class DocumentRules
|
|||||||
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
||||||
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
"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>(),
|
_ => Array.Empty<DocumentCategory>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.Diplomas;
|
||||||
using BigRegister.Domain.Documents;
|
using BigRegister.Domain.Documents;
|
||||||
using BigRegister.Domain.Intake;
|
using BigRegister.Domain.Intake;
|
||||||
|
using BigRegister.Domain.Letters;
|
||||||
using BigRegister.Domain.Registrations;
|
using BigRegister.Domain.Registrations;
|
||||||
using BigRegister.Domain.Submissions;
|
using BigRegister.Domain.Submissions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -354,16 +355,77 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
|
|||||||
.WithName("briefReset")
|
.WithName("briefReset")
|
||||||
.Produces<BriefViewDto>();
|
.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();
|
app.Run();
|
||||||
|
|
||||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
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");
|
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||||
|
|
||||||
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||||
e.ToDto(),
|
e.ToDto(),
|
||||||
BriefSeed.PassagesFor(e.Beroep),
|
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
|
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||||
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
// 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": {
|
"components": {
|
||||||
@@ -1163,6 +1395,9 @@
|
|||||||
},
|
},
|
||||||
"decisions": {
|
"decisions": {
|
||||||
"$ref": "#/components/schemas/BriefDecisionsDto"
|
"$ref": "#/components/schemas/BriefDecisionsDto"
|
||||||
|
},
|
||||||
|
"orgTemplate": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1509,6 +1744,28 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"MeDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1522,6 +1779,96 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"ParagraphDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1626,6 +1973,20 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": { }
|
"additionalProperties": { }
|
||||||
},
|
},
|
||||||
|
"PublishOrgTemplateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"affectedUnsentBriefs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ReferentieResponse": {
|
"ReferentieResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1769,6 +2130,33 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"SubmitApplicationRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,9 @@ From WP-01 onward, additionally:
|
|||||||
npm run test-storybook:ci
|
npm run test-storybook:ci
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
Phases 0–5 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
|
||||||
only needs re-running if a WP unexpectedly touches `backend/`.
|
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
|
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
|
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-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-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-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-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 | todo |
|
| [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 | todo |
|
| [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 | in-progress |
|
||||||
|
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | todo |
|
||||||
|
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
|
||||||
|
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | todo |
|
||||||
|
| [WP-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);
|
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
|
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
|
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
|
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.
|
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
|
## WP template
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-20 — Second locale proof
|
# WP-20 — Second locale proof
|
||||||
|
|
||||||
Status: done (pending commit)
|
Status: done (e276629)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
||||||
|
|
||||||
Status: done (pending commit)
|
Status: done (40dbcb2)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-22 — Durable persistence (optional tier)
|
# WP-22 — Durable persistence (optional tier)
|
||||||
|
|
||||||
Status: done (pending commit)
|
Status: done (556f2f4)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## 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).
|
||||||
91
docs/backlog/WP-24-letter-canvas.md
Normal file
91
docs/backlog/WP-24-letter-canvas.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# WP-24 — Letter canvas (edit on the letter)
|
||||||
|
|
||||||
|
Status: todo
|
||||||
|
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
|
||||||
|
|
||||||
|
- [ ] Drafter edits blocks in place on the letter surface; passage picker and
|
||||||
|
diagnostics click-to-locate still work on the canvas.
|
||||||
|
- [ ] Approver sees the identical surface read-only with the action bar.
|
||||||
|
- [ ] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
|
||||||
|
- [ ] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
|
||||||
|
- [ ] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
|
||||||
|
- [ ] Full GREEN + e2e smoke.
|
||||||
|
|
||||||
|
## 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: todo
|
||||||
|
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
|
||||||
|
|
||||||
|
- [ ] Preview opens the composed print-ready letter in a new tab; browser print
|
||||||
|
shows correct margins via `@page`.
|
||||||
|
- [ ] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
|
||||||
|
despite lint errors (only send blocks).
|
||||||
|
- [ ] A sent brief serves its archived HTML unchanged after an org-template republish.
|
||||||
|
- [ ] Golden + parity tests green without any browser installed.
|
||||||
|
- [ ] `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.
|
||||||
90
docs/backlog/WP-26-org-template-editor.md
Normal file
90
docs/backlog/WP-26-org-template-editor.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# WP-26 — Admin org-template editor
|
||||||
|
|
||||||
|
Status: todo
|
||||||
|
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
|
||||||
|
|
||||||
|
- [ ] `?role=admin` can switch sub-orgs, edit all template fields in place on the
|
||||||
|
canvas, and see the canvas update live.
|
||||||
|
- [ ] Draft saves are debounced; publish asks for confirmation showing the impact
|
||||||
|
count; after publish the drafter's canvas (WP-24) reflects it on reload.
|
||||||
|
- [ ] Version history lists published versions (who is faked, when is real);
|
||||||
|
rollback copies an old version into the draft.
|
||||||
|
- [ ] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
|
||||||
|
- [ ] Logo upload validates type/size client-side (existing `rejectReason`) and
|
||||||
|
renders on the canvas after upload.
|
||||||
|
- [ ] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
|
||||||
|
- [ ] Full GREEN.
|
||||||
|
|
||||||
|
## 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.
|
||||||
4673
documentation.json
4673
documentation.json
File diff suppressed because one or more lines are too long
@@ -2,4 +2,4 @@
|
|||||||
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
|
* 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.
|
* 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
|
* 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,
|
* 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.
|
* 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 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 {
|
export interface AantekeningDto {
|
||||||
@@ -1356,6 +1607,7 @@ export interface BriefViewDto {
|
|||||||
brief?: BriefDto;
|
brief?: BriefDto;
|
||||||
availablePassages?: LibraryPassageDto[] | undefined;
|
availablePassages?: LibraryPassageDto[] | undefined;
|
||||||
decisions?: BriefDecisionsDto;
|
decisions?: BriefDecisionsDto;
|
||||||
|
orgTemplate?: OrgTemplateDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrpAddressDto {
|
export interface BrpAddressDto {
|
||||||
@@ -1467,10 +1719,44 @@ export interface ManualDiplomaPolicyDto {
|
|||||||
policyQuestions?: PolicyQuestionDto[] | undefined;
|
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MarginsDto {
|
||||||
|
topMm?: number;
|
||||||
|
rightMm?: number;
|
||||||
|
bottomMm?: number;
|
||||||
|
leftMm?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MeDto {
|
export interface MeDto {
|
||||||
capabilities?: string[] | undefined;
|
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 {
|
export interface ParagraphDto {
|
||||||
nodes?: RichTextNodeDto[] | undefined;
|
nodes?: RichTextNodeDto[] | undefined;
|
||||||
list?: string | undefined;
|
list?: string | undefined;
|
||||||
@@ -1506,6 +1792,11 @@ export interface ProblemDetails {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublishOrgTemplateResponse {
|
||||||
|
version?: number;
|
||||||
|
affectedUnsentBriefs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReferentieResponse {
|
export interface ReferentieResponse {
|
||||||
referentie?: string | undefined;
|
referentie?: string | undefined;
|
||||||
}
|
}
|
||||||
@@ -1551,6 +1842,16 @@ export interface SaveBriefRequest {
|
|||||||
sections?: LetterSectionDto[] | undefined;
|
sections?: LetterSectionDto[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveOrgTemplateRequest {
|
||||||
|
draft?: OrgTemplateDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubOrgSummaryDto {
|
||||||
|
subOrgId?: string | undefined;
|
||||||
|
orgName?: string | undefined;
|
||||||
|
publishedVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubmitApplicationRequest {
|
export interface SubmitApplicationRequest {
|
||||||
diplomaHerkomst?: string | undefined;
|
diplomaHerkomst?: string | undefined;
|
||||||
uren?: number | undefined;
|
uren?: number | undefined;
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ describe('parseMe (trust boundary)', () => {
|
|||||||
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
|
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', () => {
|
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
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 { Capability } from '@shared/domain/capability';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
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
|
* 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';
|
import { currentRole } from './role';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only: stamps brief requests with the current `?role=` as an `X-Role` header so
|
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
|
||||||
* the backend can enforce the drafter/approver rules. Only brief endpoints carry it;
|
* header so the backend can enforce the drafter/approver/admin rules. Only the
|
||||||
* everything else is untouched.
|
* 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) => {
|
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() } }));
|
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.
|
* longer derives permission from this value itself.
|
||||||
*/
|
*/
|
||||||
export function currentRole(): Role {
|
export function currentRole(): Role {
|
||||||
return new URLSearchParams(window.location.search).get('role') === 'approver'
|
const role = new URLSearchParams(window.location.search).get('role');
|
||||||
? 'approver'
|
return role === 'approver' || role === 'admin' ? role : 'drafter';
|
||||||
: 'drafter';
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user