feat(stamdata): admin stamdata maintenance editor (beheer)
Realizes ADR-0004's "future low-code editor that commits a PR": an
admin-only stamdata maintenance editor built on the stamdata-as-code
foundation.
Backend: `professions` moves from a hardcoded C# dictionary to an embedded
`professions.json` data-file (typed as `ProfessionMapping`) with valid-time
(geldigVan/geldigTot, half-open). A generic, reflection-driven
StamdataCatalog/StamdataTable/StamdataFile describes every table so one
endpoint pair + one grid editor serve all of them; add a table in one line.
Two read-only, admin-gated endpoints (GET /stamdata, GET /stamdata/{table}
?peildatum=) — no runtime write path. Generic build gate
`Every_catalog_table_is_valid` (keys non-blank, no overlapping validity,
well-formed windows).
Frontend: new `beheer` context (route beheer/stamdata, capabilityGuard
'stamdata:edit'). A schema-driven grid editor edits rows locally; download()
emits {table}.json for the admin to commit as a reviewed PR (no mutation
command — the CI build + StamdataValidationTests stay the authority).
Full gate GREEN both sides; gen:api leaves no drift; new stamdata story
passes axe. See WP-29 + ADR-0004.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,12 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Stamdata data-files (config-as-code, ADR-0004) are embedded so they read the
|
||||
same from the running API and the test assembly — no cwd/docker-mount path fuss. -->
|
||||
<EmbeddedResource Include="Stamdata\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -46,6 +46,15 @@ public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualD
|
||||
|
||||
public sealed record IntakePolicyDto(int ScholingThreshold);
|
||||
|
||||
// --- Stamdata maintenance (ADR-0004): generic, schema-driven so ONE contract serves
|
||||
// every business-editable table. Columns are reflected from the table's typed record;
|
||||
// Rows are the raw JSON objects (opaque here — the editor renders them by column type).
|
||||
public sealed record StamdataColumnDto(string Name, string Type, bool IsKey, IReadOnlyList<string>? Options);
|
||||
public sealed record StamdataTableSummaryDto(string Id, string Label, IReadOnlyList<StamdataColumnDto> Columns, bool Temporal);
|
||||
public sealed record StamdataTableDto(
|
||||
string Id, string Label, IReadOnlyList<StamdataColumnDto> Columns, bool Temporal,
|
||||
IReadOnlyList<System.Text.Json.JsonElement> Rows);
|
||||
|
||||
// --- Document upload contracts ---
|
||||
|
||||
public sealed record DocumentCategoryDto(
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class Authz
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||
{
|
||||
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||
PrincipalRole.Admin => new[] { "orgtemplate:edit" },
|
||||
PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit" },
|
||||
_ => Array.Empty<string>(),
|
||||
};
|
||||
|
||||
@@ -64,6 +64,11 @@ public static class Authz
|
||||
/// have no per-resource state to weigh, so role IS the whole decision here.
|
||||
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
/// Stamdata maintenance (ADR-0004): admin-only, resource-independent — same shape as
|
||||
/// org-template management (role IS the decision). Gates the read-only /stamdata endpoints
|
||||
/// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here.
|
||||
public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
/// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships
|
||||
/// masked by default; only the behandelaar (Drafter) composing the case — the actor
|
||||
/// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
|
||||
|
||||
@@ -9,6 +9,7 @@ using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Letters;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using BigRegister.Stamdata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
@@ -100,6 +101,33 @@ api.MapGet("/duo/diplomas", () => new DuoLookupDto(
|
||||
|
||||
api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold));
|
||||
|
||||
// --- Stamdata maintenance (ADR-0004): generic, schema-driven reads for the admin editor.
|
||||
// One pair of endpoints serves every business-editable table; the editor renders from the
|
||||
// reflected column schema and produces an edited JSON file the admin drops into the repo
|
||||
// (the existing CI build + StamdataValidationTests stay the authority — no write endpoint).
|
||||
// Admin-gated, mirroring OrgAdmin. ---
|
||||
|
||||
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
|
||||
Results.Ok(StamdataCatalog.All.Select(t =>
|
||||
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
|
||||
.WithName("stamdataTables")
|
||||
.Produces<List<StamdataTableSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// peildatum (optional): omitted = all rows (edit view); given = only rows valid on that
|
||||
// date (the temporal preview — "which mappings applied on date X").
|
||||
api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ctx) => StamdataAdmin(ctx, () =>
|
||||
{
|
||||
var t = StamdataCatalog.Find(table);
|
||||
if (t is null) return Results.NotFound();
|
||||
var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();
|
||||
return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows));
|
||||
}))
|
||||
.WithName("stamdataTable")
|
||||
.Produces<StamdataTableDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- POST: submits. The server is the authority; it re-validates and decides. ---
|
||||
|
||||
api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
|
||||
@@ -471,6 +499,19 @@ IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// One gate for every stamdata read endpoint — the enforce twin of the `stamdata:edit`
|
||||
// capability RoleCapabilities emits (single Authz source). A denial is audited.
|
||||
IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
if (Authz.CanEditStamdata(principal)) return action();
|
||||
AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal);
|
||||
return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
static StamdataColumnDto ToColumnDto(StamdataColumn c) => new(c.Name, c.Type, c.IsKey, c.Options);
|
||||
|
||||
// Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII —
|
||||
// action, resource ref, allow/deny, acting role, correlation id. Never the value that
|
||||
// was (or wasn't) revealed. Mirrors the no-PII Submit audit below.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace BigRegister.Stamdata;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the profession↔program stamdata (config-as-code, ADR-0004): which BIG
|
||||
/// profession (beroep) a study program (opleiding) leads to, and the period that mapping
|
||||
/// is valid. The first property (<see cref="Program"/>) is the table key by convention
|
||||
/// (see <c>StamdataTable</c>); <see cref="GeldigVan"/>/<see cref="GeldigTot"/> are the
|
||||
/// valid-time window (half-open <c>[van, tot)</c>) — a null <see cref="GeldigTot"/> means
|
||||
/// "still valid". A future <see cref="GeldigVan"/> pre-schedules a mapping.
|
||||
///
|
||||
/// This is the typed shape <c>professions.json</c> deserializes into, so every consumer
|
||||
/// stays compile-typed; the authored values are gated by <c>StamdataValidationTests</c>.
|
||||
/// </summary>
|
||||
public sealed record ProfessionMapping(string Program, string Beroep, DateOnly GeldigVan, DateOnly? GeldigTot);
|
||||
@@ -1,30 +1,30 @@
|
||||
namespace BigRegister.Stamdata;
|
||||
|
||||
/// <summary>
|
||||
/// BUSINESS-EDITABLE STAMDATA (config-as-code). Which BIG profession (beroep) each
|
||||
/// study program (opleiding) maps to. This is the one table the business tunes when
|
||||
/// a program starts or stops leading to a registered profession.
|
||||
/// BUSINESS-EDITABLE STAMDATA (config-as-code). Which BIG profession (beroep) each study
|
||||
/// program (opleiding) maps to, and when that mapping is valid. The one table the business
|
||||
/// tunes when a program starts or stops leading to a registered profession.
|
||||
///
|
||||
/// Change it by editing this file and opening a PR — NOT via a production database.
|
||||
/// The C# compiler catches shape/type mistakes; <c>StamdataValidationTests</c> catches
|
||||
/// the referential integrity it can't (e.g. a seeded diploma whose program has no
|
||||
/// profession here). So a bad edit fails the build, never prod. See ADR-0004
|
||||
/// (docs/reference/architecture/0004-stamdata-as-code.md).
|
||||
/// The data lives in <c>professions.json</c> (edit it and open a PR — NOT a production
|
||||
/// database); it deserializes into <see cref="ProfessionMapping"/> here. The C# compiler
|
||||
/// checks every consumer of this typed shape; <c>StamdataValidationTests</c> checks the
|
||||
/// authored values (malformed rows, dangling references, overlapping validity). A bad edit
|
||||
/// fails the build, never prod. See ADR-0004.
|
||||
///
|
||||
/// This is DATA, not logic: the rules that consume it (which questions a diploma needs,
|
||||
/// how a manual diploma is treated) stay in <c>DiplomaRules</c>.
|
||||
/// This is DATA, not logic: the rules that consume it (which questions a diploma needs, how
|
||||
/// a manual diploma is treated) stay in <c>DiplomaRules</c>.
|
||||
/// </summary>
|
||||
public static class Professions
|
||||
{
|
||||
/// <summary>Every mapping in the data-file, typed.</summary>
|
||||
public static readonly IReadOnlyList<ProfessionMapping> Mappings = StamdataFile.Load<ProfessionMapping>("professions");
|
||||
|
||||
/// <summary>The mappings valid today, as a program→beroep lookup. Consumers that don't
|
||||
/// yet reason about a peildatum (e.g. <c>DiplomaRules.ProfessionFor</c>) use this — it
|
||||
/// preserves the pre-valid-time behaviour exactly while the file's rows are all current.</summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> ByProgram =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["geneeskunde"] = "Arts",
|
||||
["verpleegkunde"] = "Verpleegkundige",
|
||||
["fysiotherapie"] = "Fysiotherapeut",
|
||||
["farmacie"] = "Apotheker",
|
||||
["tandheelkunde"] = "Tandarts",
|
||||
};
|
||||
Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, DateOnly.FromDateTime(DateTime.Today)))
|
||||
.ToDictionary(m => m.Program, m => m.Beroep, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Distinct professions, in declaration order — the list a user may declare
|
||||
/// for a manual (unlisted) diploma.</summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace BigRegister.Stamdata;
|
||||
|
||||
/// <summary>
|
||||
/// The registry of business-editable stamdata tables (ADR-0004). This is the ONE place a
|
||||
/// new stamdata type is registered: add its JSON data-file + typed record, then one line
|
||||
/// here — the generic <c>/stamdata</c> endpoints, the grid editor, and the validation gate
|
||||
/// all pick it up with no further code.
|
||||
/// </summary>
|
||||
public static class StamdataCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<StamdataTable> All = new[]
|
||||
{
|
||||
StamdataTable.Of<ProfessionMapping>("professions", "Opleiding → beroep"),
|
||||
// PolicyQuestions and future tables migrate here, same one-liner each.
|
||||
};
|
||||
|
||||
public static StamdataTable? Find(string id) => All.FirstOrDefault(t => t.Id == id);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BigRegister.Stamdata;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a stamdata JSON data-file (config-as-code, ADR-0004). The files are embedded
|
||||
/// resources (see BigRegister.Api.csproj), so the SAME read works from the running API
|
||||
/// and from the test assembly with no file-path/cwd/docker-mount fuss — the assembly is
|
||||
/// loaded either way. A business edit is a change to the `.json` in source → rebuild →
|
||||
/// the compile/validation gate re-runs; there is no runtime write path.
|
||||
/// </summary>
|
||||
public static class StamdataFile
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>Raw JSON text of the <c>Stamdata/{id}.json</c> data-file.</summary>
|
||||
public static string Read(string id)
|
||||
{
|
||||
var asm = typeof(StamdataFile).Assembly;
|
||||
// Match on the suffix rather than composing the full logical name so a RootNamespace
|
||||
// change can't silently break the lookup.
|
||||
var name = asm.GetManifestResourceNames().SingleOrDefault(n => n.EndsWith($".Stamdata.{id}.json", StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException($"Stamdata data-file '{id}.json' is not embedded in {asm.GetName().Name}.");
|
||||
using var stream = asm.GetManifestResourceStream(name)!;
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
/// <summary>Deserialize a data-file into its typed rows.</summary>
|
||||
public static IReadOnlyList<T> Load<T>(string id) =>
|
||||
JsonSerializer.Deserialize<List<T>>(Read(id), Options)
|
||||
?? throw new InvalidOperationException($"Stamdata data-file '{id}.json' deserialized to null.");
|
||||
|
||||
/// <summary>Valid-time membership, half-open <c>[van, tot)</c>: null <c>tot</c> = open-ended.</summary>
|
||||
public static bool ActiveOn(DateOnly van, DateOnly? tot, DateOnly on) =>
|
||||
van <= on && (tot is null || on < tot);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BigRegister.Stamdata;
|
||||
|
||||
/// <summary>One editable column, derived by reflection from a stamdata record's property.</summary>
|
||||
public sealed record StamdataColumn(string Name, string Type, bool IsKey, IReadOnlyList<string>? Options = null);
|
||||
|
||||
/// <summary>
|
||||
/// A business-editable stamdata table, described generically so ONE endpoint and ONE grid
|
||||
/// editor serve every table (the "add a type with zero UI code" goal, ADR-0004). The
|
||||
/// column schema is reflected from the typed record <typeparamref name="T"/> via
|
||||
/// <see cref="Of{T}"/>; rows come from the embedded JSON data-file; <see cref="Validate"/>
|
||||
/// is the per-table half of the build-time gate.
|
||||
///
|
||||
/// Conventions: the record's FIRST property is the table key; a table is temporal iff it
|
||||
/// has both a <c>geldigVan</c> and a <c>geldigTot</c> column (half-open <c>[van, tot)</c>).
|
||||
/// </summary>
|
||||
public sealed class StamdataTable
|
||||
{
|
||||
public string Id { get; }
|
||||
public string Label { get; }
|
||||
public IReadOnlyList<StamdataColumn> Columns { get; }
|
||||
public bool Temporal { get; }
|
||||
|
||||
private readonly Action _assertParses; // throws if the file doesn't deserialize into T
|
||||
|
||||
private StamdataTable(string id, string label, IReadOnlyList<StamdataColumn> columns, Action assertParses)
|
||||
{
|
||||
Id = id;
|
||||
Label = label;
|
||||
Columns = columns;
|
||||
_assertParses = assertParses;
|
||||
Temporal = columns.Any(c => c.Name == "geldigVan") && columns.Any(c => c.Name == "geldigTot");
|
||||
}
|
||||
|
||||
public static StamdataTable Of<T>(string id, string label)
|
||||
{
|
||||
var props = typeof(T).GetProperties();
|
||||
var columns = props.Select((p, i) => new StamdataColumn(
|
||||
Name: JsonNamingPolicy.CamelCase.ConvertName(p.Name),
|
||||
Type: TypeOf(p.PropertyType, out var options),
|
||||
IsKey: i == 0,
|
||||
Options: options)).ToList();
|
||||
return new StamdataTable(id, label, columns, () => StamdataFile.Load<T>(id));
|
||||
}
|
||||
|
||||
private static string TypeOf(Type t, out IReadOnlyList<string>? options)
|
||||
{
|
||||
options = null;
|
||||
var u = Nullable.GetUnderlyingType(t) ?? t;
|
||||
if (u == typeof(DateOnly)) return "date";
|
||||
if (u == typeof(int) || u == typeof(long)) return "number";
|
||||
if (u.IsEnum) { options = Enum.GetNames(u); return "enum"; }
|
||||
return "text";
|
||||
}
|
||||
|
||||
/// <summary>All rows as generic JSON objects (edit view).</summary>
|
||||
public JsonElement[] Rows() =>
|
||||
JsonSerializer.Deserialize<JsonElement[]>(StamdataFile.Read(Id))!;
|
||||
|
||||
/// <summary>Rows valid on <paramref name="on"/> (temporal tables); all rows otherwise.</summary>
|
||||
public JsonElement[] RowsOn(DateOnly on) =>
|
||||
Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows();
|
||||
|
||||
private static bool ActiveOn(JsonElement row, DateOnly on)
|
||||
{
|
||||
var van = DateOnly.Parse(row.GetProperty("geldigVan").GetString()!);
|
||||
DateOnly? tot = row.TryGetProperty("geldigTot", out var t) && t.ValueKind != JsonValueKind.Null
|
||||
? DateOnly.Parse(t.GetString()!) : null;
|
||||
return StamdataFile.ActiveOn(van, tot, on);
|
||||
}
|
||||
|
||||
/// <summary>Referential-integrity checks the C# type system can't express (ADR-0004's
|
||||
/// second gate). Returns human-readable problems; empty = valid.</summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
try { _assertParses(); }
|
||||
catch (Exception ex) { return new[] { $"{Id}.json failed to parse into its typed shape: {ex.Message}" }; }
|
||||
|
||||
var problems = new List<string>();
|
||||
var key = Columns[0].Name;
|
||||
var rows = Rows();
|
||||
|
||||
foreach (var (row, idx) in rows.Select((r, i) => (r, i)))
|
||||
{
|
||||
var k = row.TryGetProperty(key, out var kv) ? kv.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(k)) problems.Add($"{Id}.json row {idx} has a blank '{key}'.");
|
||||
if (Temporal)
|
||||
{
|
||||
var van = DateOnly.Parse(row.GetProperty("geldigVan").GetString()!);
|
||||
if (row.TryGetProperty("geldigTot", out var t) && t.ValueKind != JsonValueKind.Null
|
||||
&& DateOnly.Parse(t.GetString()!) <= van)
|
||||
problems.Add($"{Id}.json row {idx} ('{k}') has geldigTot <= geldigVan.");
|
||||
}
|
||||
}
|
||||
|
||||
// No key may have two mappings valid at the same time.
|
||||
foreach (var g in rows.GroupBy(r => r.GetProperty(key).GetString()))
|
||||
if (Overlaps(g))
|
||||
problems.Add($"{Id}.json has overlapping validity periods for '{g.Key}'.");
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
private bool Overlaps(IEnumerable<JsonElement> group)
|
||||
{
|
||||
if (!Temporal) return group.Count() > 1; // non-temporal: any duplicate key is a conflict
|
||||
var periods = group.Select(r =>
|
||||
{
|
||||
var van = DateOnly.Parse(r.GetProperty("geldigVan").GetString()!);
|
||||
DateOnly tot = r.TryGetProperty("geldigTot", out var t) && t.ValueKind != JsonValueKind.Null
|
||||
? DateOnly.Parse(t.GetString()!) : DateOnly.MaxValue;
|
||||
return (van, tot);
|
||||
}).OrderBy(p => p.van).ToList();
|
||||
for (var i = 1; i < periods.Count; i++)
|
||||
if (periods[i].van < periods[i - 1].tot) return true; // half-open [van, tot)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{ "program": "geneeskunde", "beroep": "Arts", "geldigVan": "2000-01-01", "geldigTot": null },
|
||||
{ "program": "verpleegkunde", "beroep": "Verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": null },
|
||||
{ "program": "fysiotherapie", "beroep": "Fysiotherapeut", "geldigVan": "2000-01-01", "geldigTot": null },
|
||||
{ "program": "farmacie", "beroep": "Apotheker", "geldigVan": "2000-01-01", "geldigTot": null },
|
||||
{ "program": "tandheelkunde", "beroep": "Tandarts", "geldigVan": "2000-01-01", "geldigTot": null }
|
||||
]
|
||||
@@ -127,6 +127,89 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/stamdata": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "stamdataTables",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StamdataTableSummaryDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/stamdata/{table}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "stamdataTable",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "table",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "peildatum",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StamdataTableDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/registrations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2178,6 +2261,83 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"StamdataColumnDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"isKey": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"StamdataTableDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StamdataColumnDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"temporal": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": { },
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"StamdataTableSummaryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StamdataColumnDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"temporal": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubOrgSummaryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -201,7 +201,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
{
|
||||
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);
|
||||
Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit" }, me!.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The stamdata maintenance reads (ADR-0004): admin-only, generic (schema + rows), and the
|
||||
/// valid-time peildatum filter. The editor consumes these; the edit itself lands as a PR.
|
||||
/// </summary>
|
||||
public class StamdataEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path);
|
||||
if (role is not null) req.Headers.Add("X-Role", role);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stamdata_reads_are_admin_only()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions", role: "drafter"))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Table_list_exposes_the_reflected_schema()
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var tables = (await res.Content.ReadFromJsonAsync<List<StamdataTableSummaryDto>>())!;
|
||||
var professions = tables.Single(t => t.Id == "professions");
|
||||
Assert.True(professions.Temporal);
|
||||
Assert.True(professions.Columns[0].IsKey);
|
||||
Assert.Equal("program", professions.Columns[0].Name);
|
||||
Assert.Contains(professions.Columns, c => c.Name == "geldigVan" && c.Type == "date");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Table_returns_all_rows_without_a_peildatum()
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var table = (await res.Content.ReadFromJsonAsync<StamdataTableDto>())!;
|
||||
Assert.Equal(5, table.Rows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Peildatum_before_the_seed_windows_hides_every_row()
|
||||
{
|
||||
// Seed mappings start 2000-01-01; a 1999 peildatum yields none (valid-time filter works).
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions?peildatum=1999-01-01", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var table = (await res.Content.ReadFromJsonAsync<StamdataTableDto>())!;
|
||||
Assert.Empty(table.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_table_is_404()
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/nope", role: "admin"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,16 @@ public class StamdataValidationTests
|
||||
var ids = PolicyQuestions.ManualSet.Select(q => q.Id).ToList();
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
// The GENERIC gate (ADR-0004): every table registered in the catalog is validated the
|
||||
// same way — its JSON deserializes into its typed record, keys are non-blank and don't
|
||||
// overlap in time, and valid-time windows are well-formed. A new stamdata type is covered
|
||||
// the moment it's added to StamdataCatalog; no new test needed. A bad edit fails the build.
|
||||
[Fact]
|
||||
public void Every_catalog_table_is_valid()
|
||||
{
|
||||
foreach (var table in StamdataCatalog.All)
|
||||
Assert.True(table.Validate().Count == 0,
|
||||
$"Stamdata table '{table.Id}' has problems: {string.Join("; ", table.Validate())}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user