style: format backend with dotnet format

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-03 13:39:31 +02:00
co-authored by Claude Opus 4.8
parent e82309786d
commit 1137f59f7b
17 changed files with 1135 additions and 1129 deletions
@@ -9,53 +9,53 @@ namespace BigRegister.Api.Contracts;
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
public static class Mappers
{
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null)
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new("Goedgekeurd", Referentie: a.Referentie);
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
}
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null)
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new("Goedgekeurd", Referentie: a.Referentie);
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
}
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
}
@@ -12,20 +12,20 @@ namespace BigRegister.Api.Data;
/// </summary>
public sealed class Aanvraag
{
public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; }
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
public int StepIndex { get; set; }
public int StepCount { get; set; }
public List<string> DocumentIds { get; set; } = new();
public string? Referentie { get; set; } // set on submit
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
public bool Submitted { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; }
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
public int StepIndex { get; set; }
public int StepCount { get; set; }
public List<string> DocumentIds { get; set; } = new();
public string? Referentie { get; set; } // set on submit
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
public bool Submitted { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
}
/// <summary>
@@ -35,76 +35,76 @@ public sealed class Aanvraag
/// </summary>
public static class ApplicationStore
{
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
private static readonly Dictionary<string, Aanvraag> _apps = new();
private static readonly object _gate = new();
private static readonly Dictionary<string, Aanvraag> _apps = new();
private static readonly object _gate = new();
public static Aanvraag Create(string type, string owner)
public static Aanvraag Create(string type, string owner)
{
var now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate) _apps[a.Id] = a;
return a;
}
public static Aanvraag? Get(string id, string owner)
{
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
}
public static IReadOnlyList<Aanvraag> List(string owner)
{
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
}
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
var now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate) _apps[a.Id] = a;
return a;
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
return true;
}
}
public static Aanvraag? Get(string id, string owner)
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner)
{
List<string> docs;
lock (_gate)
{
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
_apps.Remove(id);
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
}
public static IReadOnlyList<Aanvraag> List(string owner)
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
}
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
return true;
}
}
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner)
{
List<string> docs;
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
_apps.Remove(id);
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
}
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
return a;
}
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
return a;
}
}
}
+113 -113
View File
@@ -11,134 +11,134 @@ namespace BigRegister.Api.Data;
/// </summary>
public sealed class BriefEntity
{
public required string BriefId { get; init; }
public required string Owner { get; init; }
public required string Beroep { get; init; }
public required string TemplateId { get; init; }
public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft");
public required string BriefId { get; init; }
public required string Owner { get; init; }
public required string Beroep { get; init; }
public required string TemplateId { get; init; }
public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft");
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
}
public static class BriefStore
{
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver";
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver";
public enum Outcome { Ok, Forbidden, Conflict }
public enum Outcome { Ok, Forbidden, Conflict }
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
private static readonly object _gate = new();
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner)
public static BriefEntity GetOrCreate(string owner)
{
lock (_gate)
{
lock (_gate)
{
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
}
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
return (Outcome.Ok, e);
}
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
return (Outcome.Ok, e);
}
}
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
return (Outcome.Ok, e);
}
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
return (Outcome.Ok, e);
}
}
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Send(string owner, string at)
public static (Outcome, BriefEntity?) Send(string owner, string at)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
return (Outcome.Ok, e);
}
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
return (Outcome.Ok, e);
}
}
/// Test seam: clear the demo brief between tests.
public static void Reset()
/// Test seam: clear the demo brief between tests.
public static void Reset()
{
lock (_gate) _byOwner.Clear();
}
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
/// "start over" for the showcase, not a real workflow transition.
public static BriefEntity ResetAndCreate(string owner)
{
lock (_gate)
{
lock (_gate) _byOwner.Clear();
_byOwner.Remove(owner);
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
}
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
/// "start over" for the showcase, not a real workflow transition.
public static BriefEntity ResetAndCreate(string owner)
// Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter).
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
{
lock (_gate)
{
lock (_gate)
{
_byOwner.Remove(owner);
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e);
return (Outcome.Ok, e);
}
}
// Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter).
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e);
return (Outcome.Ok, e);
}
}
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
}
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
public static class BriefSeed
{
public const string TemplateId = "besluit-arts";
public const string Beroep = "arts";
public const string TemplateId = "besluit-arts";
public const string Beroep = "arts";
private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
// The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{
// The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
@@ -147,18 +147,18 @@ public static class BriefSeed
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
};
public static BriefEntity NewBrief(string owner) => new()
{
BriefId = Guid.NewGuid().ToString(),
Owner = owner,
Beroep = Beroep,
TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders,
// aanhef + slot are predefined, locked template text (prefilled); the drafter
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
Sections = new()
public static BriefEntity NewBrief(string owner) => new()
{
BriefId = Guid.NewGuid().ToString(),
Owner = owner,
Beroep = Beroep,
TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders,
// aanhef + slot are predefined, locked template text (prefilled); the drafter
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
Sections = new()
{
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
{
@@ -170,13 +170,13 @@ public static class BriefSeed
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
}, Locked: true),
},
Status = new BriefStatusDto("draft"),
};
Status = new BriefStatusDto("draft"),
};
/// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{
var all = new List<LibraryPassageDto>
/// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{
var all = new List<LibraryPassageDto>
{
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
@@ -193,6 +193,6 @@ public static class BriefSeed
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
};
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
}
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
}
}
@@ -10,7 +10,7 @@ public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{
public bool Linked { get; set; }
public bool Linked { get; set; }
}
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
@@ -22,81 +22,81 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new();
private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
public static StoredDocument? Get(string documentId)
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
}
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
}
}
+17 -17
View File
@@ -10,31 +10,31 @@ namespace BigRegister.Api.Data;
/// </summary>
public static class SeedData
{
public static readonly Registration Registration = new(
BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Registration Registration = new(
BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
/// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres;
/// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres;
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
{
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
{
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
};
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
{
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
{
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
@@ -2,8 +2,8 @@ namespace BigRegister.Domain.Diplomas;
public enum QuestionType
{
JaNee,
Tekst,
JaNee,
Tekst,
}
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
@@ -8,61 +8,61 @@ namespace BigRegister.Domain.Diplomas;
/// </summary>
public static class DiplomaRules
{
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList();
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList();
// --- Policy questions (geldigheidsvragen) ---
// --- Policy questions (geldigheidsvragen) ---
private static readonly PolicyQuestion NlTaalEngelstalig = new(
"nl-taalvaardigheid",
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalEngelstalig = new(
"nl-taalvaardigheid",
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalManual = new(
"nl-taalvaardigheid",
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalManual = new(
"nl-taalvaardigheid",
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion DiplomaErkend = new(
"diploma-erkend",
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
QuestionType.JaNee);
private static readonly PolicyQuestion DiplomaErkend = new(
"diploma-erkend",
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
QuestionType.JaNee);
private static readonly PolicyQuestion Toelichting = new(
"toelichting",
"Geef een korte toelichting op uw diploma en opleiding.",
QuestionType.Tekst);
private static readonly PolicyQuestion Toelichting = new(
"toelichting",
"Geef een korte toelichting op uw diploma en opleiding.",
QuestionType.Tekst);
/// <summary>
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
/// change, no frontend change.
/// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
return questions;
}
/// <summary>
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
/// change, no frontend change.
/// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
return questions;
}
/// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting };
/// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting };
}
@@ -17,67 +17,67 @@ public sealed record DocumentCategory(
public static class DocumentRules
{
private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
/// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers.
/// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
/// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers.
/// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
{
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
"herregistratie" => new[]
{
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
"herregistratie" => new[]
{
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
},
_ => Array.Empty<DocumentCategory>(),
};
_ => Array.Empty<DocumentCategory>(),
};
/// <summary>
/// The categories to PRESENT for a wizard, given the answers that affect required
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
/// wizards get their full set.
/// </summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
{
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
var result = new List<DocumentCategory>();
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
result.Add(Identiteit);
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
return result;
}
/// <summary>
/// The categories to PRESENT for a wizard, given the answers that affect required
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
/// wizards get their full set.
/// </summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
{
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
var result = new List<DocumentCategory>();
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
result.Add(Identiteit);
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
return result;
}
public static DocumentCategory? Find(string wizardId, string categoryId) =>
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
public static DocumentCategory? Find(string wizardId, string categoryId) =>
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{
if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null;
}
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{
if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null;
}
}
@@ -7,5 +7,5 @@ namespace BigRegister.Domain.Intake;
/// </summary>
public static class IntakePolicy
{
public const int ScholingThreshold = 1000;
public const int ScholingThreshold = 1000;
}
@@ -8,25 +8,25 @@ namespace BigRegister.Domain.Registrations;
/// </summary>
public static class HerregistratieRule
{
public const int WindowMonths = 12;
public const int WindowMonths = 12;
public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths)
{
var deadline = Deadline(reg);
if (deadline is null)
return (false, "Geen actieve registratie.");
public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths)
{
var deadline = Deadline(reg);
if (deadline is null)
return (false, "Geen actieve registratie.");
var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
}
var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
}
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
}
@@ -3,9 +3,9 @@ namespace BigRegister.Domain.Registrations;
/// <summary>The three states a BIG registration can be in.</summary>
public enum StatusTag
{
Geregistreerd,
Geschorst,
Doorgehaald,
Geregistreerd,
Geschorst,
Doorgehaald,
}
/// <summary>
@@ -9,29 +9,29 @@ namespace BigRegister.Domain.Submissions;
/// </summary>
public static class SubmissionRules
{
// RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null;
// RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null;
// RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
// RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// RULE: a change request needs a street and a well-formed Dutch postcode. The
// server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode)
{
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null;
}
// RULE: a change request needs a street and a well-formed Dutch postcode. The
// server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode)
{
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null;
}
public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
}
+116 -116
View File
@@ -16,8 +16,8 @@ builder.Services.AddSwaggerGen(c =>
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
const string SpaCors = "spa";
@@ -42,10 +42,10 @@ var api = app.MapGroup("/api/v1");
api.MapGet("/dashboard-view", () =>
{
var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason));
var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason));
});
api.MapGet("/notes", () =>
@@ -96,21 +96,21 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
// and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) =>
{
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
})
.ExcludeFromDescription();
@@ -118,10 +118,10 @@ api.MapPost("/uploads", async (HttpRequest request) =>
// for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
@@ -129,23 +129,23 @@ api.MapGet("/uploads/{documentId}/content", (string documentId) =>
// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) =>
{
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
});
// User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) =>
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
{
DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(),
DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(),
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
@@ -164,10 +164,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
api.MapGet("/applications", () =>
{
var now = DateTimeOffset.UtcNow;
return ApplicationStore.List(DocumentStore.DemoOwner)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
var now = DateTimeOffset.UtcNow;
return ApplicationStore.List(DocumentStore.DemoOwner)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
});
api.MapGet("/applications/{id}", (string id) =>
@@ -179,8 +179,8 @@ api.MapGet("/applications/{id}", (string id) =>
api.MapPost("/applications", (CreateApplicationRequest req) =>
{
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
})
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
@@ -195,12 +195,12 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
// be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id) =>
{
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (a is null) return Results.NotFound();
if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
return Results.NoContent();
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (a is null) return Results.NotFound();
if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
return Results.NoContent();
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
@@ -210,30 +210,30 @@ api.MapDelete("/applications/{id}", (string id) =>
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
{
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound();
if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound();
if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
(string? reject, bool autoApprovable) = existing.Type switch
{
"registratie" => (null, req.DiplomaHerkomst == "duo"),
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
};
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
(string? reject, bool autoApprovable) = existing.Type switch
{
"registratie" => (null, req.DiplomaHerkomst == "duo"),
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
};
var docs = req.Documents;
if (docs is not null)
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var docs = req.Documents;
if (docs is not null)
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict();
app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
})
.Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status409Conflict)
@@ -245,15 +245,15 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
api.MapGet("/brief", () =>
{
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
})
.Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
var (_, isDrafter) = BriefRole(ctx);
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -261,10 +261,10 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/submit", (HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen.");
var (_, isDrafter) = BriefRole(ctx);
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen.");
})
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>()
@@ -273,10 +273,10 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
api.MapPost("/brief/approve", (HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
LogBrief("approve", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
LogBrief("approve", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -284,10 +284,10 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
LogBrief("reject", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
LogBrief("reject", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -295,20 +295,20 @@ api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/send", () =>
{
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition.
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status.");
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition.
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reset", () =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
})
.WithName("briefReset")
.Produces<BriefViewDto>();
@@ -323,15 +323,15 @@ static string Now() => DateTimeOffset.UtcNow.ToString("o");
// else (default) acts as the drafter.
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
{
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
}
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
{
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
};
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
@@ -343,30 +343,30 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
// real system ships this to structured logging / an audit store).
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: "none";
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: "none";
if (reject is not null)
{
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
}
if (reject is not null)
{
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
}
if (documents is not null)
{
// Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document.
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
}
if (documents is not null)
{
// Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document.
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
}
var reference = SubmissionRules.NewReference();
app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference));
var reference = SubmissionRules.NewReference();
app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference));
}
// Exposed so the integration tests can spin up the app with WebApplicationFactory.