style: format backend with dotnet format
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,53 +9,53 @@ namespace BigRegister.Api.Contracts;
|
|||||||
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
|
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
|
||||||
public static class Mappers
|
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(
|
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
|
||||||
Tag: s.Tag.ToString(),
|
Tag: s.Tag.ToString(),
|
||||||
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
||||||
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
||||||
Reden: s.Reden,
|
Reden: s.Reden,
|
||||||
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
||||||
|
|
||||||
public static RegistrationDto ToDto(this Registration r) => new(
|
public static RegistrationDto ToDto(this Registration r) => new(
|
||||||
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
|
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(
|
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
|
||||||
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
|
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
|
||||||
|
|
||||||
public static DuoDiplomaDto ToDto(this Diploma d) => new(
|
public static DuoDiplomaDto ToDto(this Diploma d) => new(
|
||||||
d.Id, d.Naam, d.Instelling, d.Jaar,
|
d.Id, d.Naam, d.Instelling, d.Jaar,
|
||||||
DiplomaRules.ProfessionFor(d),
|
DiplomaRules.ProfessionFor(d),
|
||||||
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
|
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
|
||||||
|
|
||||||
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
||||||
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
|
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
|
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
||||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
||||||
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
|
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
|
||||||
// by passing different `now` values without waiting for the wall clock.
|
// by passing different `now` values without waiting for the wall clock.
|
||||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
if (!a.Submitted)
|
if (!a.Submitted)
|
||||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
||||||
if (a.Reden is not null)
|
if (a.Reden is not null)
|
||||||
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
|
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
|
||||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||||
return new("Goedgekeurd", Referentie: a.Referentie);
|
return new("Goedgekeurd", Referentie: a.Referentie);
|
||||||
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||||
|
|
||||||
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||||
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
||||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ namespace BigRegister.Api.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Aanvraag
|
public sealed class Aanvraag
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Type { get; init; } // registratie | herregistratie | intake
|
public required string Type { get; init; } // registratie | herregistratie | intake
|
||||||
public required string Owner { get; init; }
|
public required string Owner { get; init; }
|
||||||
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
|
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
|
||||||
public int StepIndex { get; set; }
|
public int StepIndex { get; set; }
|
||||||
public int StepCount { get; set; }
|
public int StepCount { get; set; }
|
||||||
public List<string> DocumentIds { get; set; } = new();
|
public List<string> DocumentIds { get; set; } = new();
|
||||||
public string? Referentie { get; set; } // set on submit
|
public string? Referentie { get; set; } // set on submit
|
||||||
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
|
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
|
||||||
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
|
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
|
||||||
public bool Submitted { get; set; }
|
public bool Submitted { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
public DateTimeOffset UpdatedAt { get; set; }
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
public DateTimeOffset? SubmittedAt { get; set; }
|
public DateTimeOffset? SubmittedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -35,76 +35,76 @@ public sealed class Aanvraag
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ApplicationStore
|
public static class ApplicationStore
|
||||||
{
|
{
|
||||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||||
|
|
||||||
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
||||||
private static readonly object _gate = 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;
|
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
|
||||||
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
||||||
lock (_gate) _apps[a.Id] = a;
|
a.StepIndex = stepIndex;
|
||||||
return a;
|
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();
|
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
|
||||||
}
|
a.Submitted = true;
|
||||||
|
a.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
a.UpdatedAt = a.SubmittedAt.Value;
|
||||||
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
|
a.Referentie = SubmissionRules.NewReference();
|
||||||
{
|
a.AutoApprovable = autoApprovable;
|
||||||
lock (_gate)
|
a.Reden = reject;
|
||||||
{
|
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
|
return a;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,134 +11,134 @@ namespace BigRegister.Api.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BriefEntity
|
public sealed class BriefEntity
|
||||||
{
|
{
|
||||||
public required string BriefId { get; init; }
|
public required string BriefId { get; init; }
|
||||||
public required string Owner { get; init; }
|
public required string Owner { get; init; }
|
||||||
public required string Beroep { get; init; }
|
public required string Beroep { get; init; }
|
||||||
public required string TemplateId { get; init; }
|
public required string TemplateId { get; init; }
|
||||||
public required string DrafterId { get; init; }
|
public required string DrafterId { get; init; }
|
||||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||||
public BriefStatusDto Status { get; set; } = new("draft");
|
public BriefStatusDto Status { get; set; } = new("draft");
|
||||||
|
|
||||||
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
|
public static class BriefStore
|
||||||
{
|
{
|
||||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||||
public const string DrafterId = "demo-drafter";
|
public const string DrafterId = "demo-drafter";
|
||||||
public const string ApproverId = "demo-approver";
|
public const string ApproverId = "demo-approver";
|
||||||
|
|
||||||
public enum Outcome { Ok, Forbidden, Conflict }
|
public enum Outcome { Ok, Forbidden, Conflict }
|
||||||
|
|
||||||
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
||||||
private static readonly object _gate = 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);
|
||||||
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
|
_byOwner[owner] = created;
|
||||||
var created = BriefSeed.NewBrief(owner);
|
return created;
|
||||||
_byOwner[owner] = created;
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
|
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
|
||||||
/// letter reopens to draft on edit (mirrors the FE reducer).
|
/// letter reopens to draft on edit (mirrors the FE reducer).
|
||||||
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
|
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 (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
e.Sections = sections.ToList();
|
||||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||||
e.Sections = sections.ToList();
|
return (Outcome.Ok, e);
|
||||||
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 (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
return (Outcome.Ok, e);
|
||||||
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) =>
|
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
|
||||||
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: 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) =>
|
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));
|
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);
|
||||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
return (Outcome.Ok, e);
|
||||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
|
||||||
return (Outcome.Ok, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Test seam: clear the demo brief between tests.
|
/// Test seam: clear the demo brief between tests.
|
||||||
public static void Reset()
|
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
|
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||||
/// "start over" for the showcase, not a real workflow transition.
|
// from the drafter (a drafter cannot approve their own letter).
|
||||||
public static BriefEntity ResetAndCreate(string owner)
|
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||||
{
|
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
|
||||||
_byOwner.Remove(owner);
|
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||||
var created = BriefSeed.NewBrief(owner);
|
e.Status = next(e);
|
||||||
_byOwner[owner] = created;
|
return (Outcome.Ok, e);
|
||||||
return created;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Approve/reject share the guard: must be submitted, and the approver must differ
|
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
|
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
|
||||||
public static class BriefSeed
|
public static class BriefSeed
|
||||||
{
|
{
|
||||||
public const string TemplateId = "besluit-arts";
|
public const string TemplateId = "besluit-arts";
|
||||||
public const string Beroep = "arts";
|
public const string Beroep = "arts";
|
||||||
|
|
||||||
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
||||||
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
||||||
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
|
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:
|
// The template's valid placeholder fields. Two are flagged to exercise the linter:
|
||||||
// a deprecated field and one not fillable for this beroep.
|
// a deprecated field and one not fillable for this beroep.
|
||||||
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
|
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
|
||||||
{
|
{
|
||||||
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
|
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
|
||||||
new PlaceholderDefDto("datum", "Datum", true),
|
new PlaceholderDefDto("datum", "Datum", true),
|
||||||
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
|
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
|
||||||
@@ -147,18 +147,18 @@ public static class BriefSeed
|
|||||||
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
|
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static BriefEntity NewBrief(string owner) => new()
|
public static BriefEntity NewBrief(string owner) => new()
|
||||||
{
|
{
|
||||||
BriefId = Guid.NewGuid().ToString(),
|
BriefId = Guid.NewGuid().ToString(),
|
||||||
Owner = owner,
|
Owner = owner,
|
||||||
Beroep = Beroep,
|
Beroep = Beroep,
|
||||||
TemplateId = TemplateId,
|
TemplateId = TemplateId,
|
||||||
DrafterId = BriefStore.DrafterId,
|
DrafterId = BriefStore.DrafterId,
|
||||||
Placeholders = Placeholders,
|
Placeholders = Placeholders,
|
||||||
// aanhef + slot are predefined, locked template text (prefilled); the drafter
|
// aanhef + slot are predefined, locked template text (prefilled); the drafter
|
||||||
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
|
// 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 (FE-authoritative for this POC — the FE reducer also refuses it).
|
||||||
Sections = new()
|
Sections = new()
|
||||||
{
|
{
|
||||||
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
|
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
|
||||||
{
|
{
|
||||||
@@ -170,13 +170,13 @@ public static class BriefSeed
|
|||||||
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
|
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
|
||||||
}, Locked: true),
|
}, Locked: true),
|
||||||
},
|
},
|
||||||
Status = new BriefStatusDto("draft"),
|
Status = new BriefStatusDto("draft"),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Global passages plus those scoped to the given beroep.
|
/// Global passages plus those scoped to the given beroep.
|
||||||
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
|
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
|
||||||
{
|
{
|
||||||
var all = new List<LibraryPassageDto>
|
var all = new List<LibraryPassageDto>
|
||||||
{
|
{
|
||||||
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
|
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
|
||||||
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
|
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)",
|
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
|
||||||
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
|
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 DocumentId, string LocalId, string CategoryId, string WizardId,
|
||||||
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
|
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);
|
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>
|
/// </summary>
|
||||||
public static class DocumentStore
|
public static class DocumentStore
|
||||||
{
|
{
|
||||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||||
public const string DemoOwner = "19012345601";
|
public const string DemoOwner = "19012345601";
|
||||||
|
|
||||||
private static readonly Dictionary<string, StoredDocument> _docs = new();
|
private static readonly Dictionary<string, StoredDocument> _docs = new();
|
||||||
private static readonly List<AuditEntry> _audit = new();
|
private static readonly List<AuditEntry> _audit = new();
|
||||||
private static readonly object _gate = 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);
|
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
|
||||||
lock (_gate) _docs[doc.DocumentId] = doc;
|
if (d.Linked) return DeleteResult.Linked;
|
||||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
categoryId = d.CategoryId;
|
||||||
return doc;
|
_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
|
public static void Audit(string action, string documentId, string categoryId, string actor)
|
||||||
/// arrived), an unknown one is still in flight / never started.
|
{
|
||||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||||
{
|
}
|
||||||
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 IReadOnlyList<AuditEntry> AuditLog
|
||||||
public static void Link(IEnumerable<string> documentIds)
|
{
|
||||||
{
|
get { lock (_gate) return _audit.ToList(); }
|
||||||
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(); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,31 +10,31 @@ namespace BigRegister.Api.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SeedData
|
public static class SeedData
|
||||||
{
|
{
|
||||||
public static readonly Registration Registration = new(
|
public static readonly Registration Registration = new(
|
||||||
BigNummer: "19012345601",
|
BigNummer: "19012345601",
|
||||||
Naam: "Dr. A. (Anna) de Vries",
|
Naam: "Dr. A. (Anna) de Vries",
|
||||||
Beroep: "Arts",
|
Beroep: "Arts",
|
||||||
Registratiedatum: new DateOnly(2012, 9, 1),
|
Registratiedatum: new DateOnly(2012, 9, 1),
|
||||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||||
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||||
|
|
||||||
public static readonly Person Person = new(
|
public static readonly Person Person = new(
|
||||||
Naam: "Dr. A. (Anna) de Vries",
|
Naam: "Dr. A. (Anna) de Vries",
|
||||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||||
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
|
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
|
||||||
|
|
||||||
/// <summary>The address BRP returns for the seeded citizen.</summary>
|
/// <summary>The address BRP returns for the seeded citizen.</summary>
|
||||||
public static readonly Adres BrpAddress = Person.Adres;
|
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("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
|
||||||
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
|
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
|
||||||
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
|
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"),
|
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
|
||||||
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
|
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
|
||||||
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
|
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ namespace BigRegister.Domain.Diplomas;
|
|||||||
|
|
||||||
public enum QuestionType
|
public enum QuestionType
|
||||||
{
|
{
|
||||||
JaNee,
|
JaNee,
|
||||||
Tekst,
|
Tekst,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
|
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
|
||||||
|
|||||||
@@ -8,61 +8,61 @@ namespace BigRegister.Domain.Diplomas;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DiplomaRules
|
public static class DiplomaRules
|
||||||
{
|
{
|
||||||
// RULE: study program → BIG profession.
|
// RULE: study program → BIG profession.
|
||||||
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["geneeskunde"] = "Arts",
|
["geneeskunde"] = "Arts",
|
||||||
["verpleegkunde"] = "Verpleegkundige",
|
["verpleegkunde"] = "Verpleegkundige",
|
||||||
["fysiotherapie"] = "Fysiotherapeut",
|
["fysiotherapie"] = "Fysiotherapeut",
|
||||||
["farmacie"] = "Apotheker",
|
["farmacie"] = "Apotheker",
|
||||||
["tandheelkunde"] = "Tandarts",
|
["tandheelkunde"] = "Tandarts",
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string ProfessionFor(Diploma d) =>
|
public static string ProfessionFor(Diploma d) =>
|
||||||
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||||
|
|
||||||
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
||||||
public static IReadOnlyList<string> ManualProfessions() =>
|
public static IReadOnlyList<string> ManualProfessions() =>
|
||||||
ProfessionByProgram.Values.Distinct().ToList();
|
ProfessionByProgram.Values.Distinct().ToList();
|
||||||
|
|
||||||
// --- Policy questions (geldigheidsvragen) ---
|
// --- Policy questions (geldigheidsvragen) ---
|
||||||
|
|
||||||
private static readonly PolicyQuestion NlTaalEngelstalig = new(
|
private static readonly PolicyQuestion NlTaalEngelstalig = new(
|
||||||
"nl-taalvaardigheid",
|
"nl-taalvaardigheid",
|
||||||
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||||
QuestionType.JaNee);
|
QuestionType.JaNee);
|
||||||
|
|
||||||
private static readonly PolicyQuestion NlTaalManual = new(
|
private static readonly PolicyQuestion NlTaalManual = new(
|
||||||
"nl-taalvaardigheid",
|
"nl-taalvaardigheid",
|
||||||
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||||
QuestionType.JaNee);
|
QuestionType.JaNee);
|
||||||
|
|
||||||
private static readonly PolicyQuestion DiplomaErkend = new(
|
private static readonly PolicyQuestion DiplomaErkend = new(
|
||||||
"diploma-erkend",
|
"diploma-erkend",
|
||||||
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
|
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
|
||||||
QuestionType.JaNee);
|
QuestionType.JaNee);
|
||||||
|
|
||||||
private static readonly PolicyQuestion Toelichting = new(
|
private static readonly PolicyQuestion Toelichting = new(
|
||||||
"toelichting",
|
"toelichting",
|
||||||
"Geef een korte toelichting op uw diploma en opleiding.",
|
"Geef een korte toelichting op uw diploma en opleiding.",
|
||||||
QuestionType.Tekst);
|
QuestionType.Tekst);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
|
/// 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
|
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
|
||||||
/// change, no frontend change.
|
/// change, no frontend change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||||
{
|
{
|
||||||
var questions = new List<PolicyQuestion>();
|
var questions = new List<PolicyQuestion>();
|
||||||
if (d.Engelstalig)
|
if (d.Engelstalig)
|
||||||
questions.Add(NlTaalEngelstalig);
|
questions.Add(NlTaalEngelstalig);
|
||||||
return questions;
|
return questions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
|
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
|
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
|
||||||
new[] { NlTaalManual, DiplomaErkend, Toelichting };
|
new[] { NlTaalManual, DiplomaErkend, Toelichting };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,67 +17,67 @@ public sealed record DocumentCategory(
|
|||||||
|
|
||||||
public static class DocumentRules
|
public static class DocumentRules
|
||||||
{
|
{
|
||||||
private static readonly string[] Pdf = { "application/pdf" };
|
private static readonly string[] Pdf = { "application/pdf" };
|
||||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
||||||
|
|
||||||
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||||
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
|
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
|
||||||
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
|
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
|
||||||
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
|
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);
|
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
|
/// 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"/>
|
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
|
||||||
/// decides which subset is actually presented for a given set of answers.
|
/// decides which subset is actually presented for a given set of answers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
|
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",
|
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
|
||||||
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
|
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
|
||||||
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
||||||
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
||||||
},
|
},
|
||||||
_ => Array.Empty<DocumentCategory>(),
|
_ => Array.Empty<DocumentCategory>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The categories to PRESENT for a wizard, given the answers that affect required
|
/// 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
|
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
|
||||||
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
|
/// 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
|
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
|
||||||
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
|
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
|
||||||
/// wizards get their full set.
|
/// wizards get their full set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<DocumentCategory> CategoriesFor(
|
public static IReadOnlyList<DocumentCategory> CategoriesFor(
|
||||||
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
|
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
|
||||||
{
|
{
|
||||||
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
|
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
|
||||||
var result = new List<DocumentCategory>();
|
var result = new List<DocumentCategory>();
|
||||||
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
|
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
|
||||||
result.Add(Identiteit);
|
result.Add(Identiteit);
|
||||||
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
|
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static DocumentCategory? Find(string wizardId, string categoryId) =>
|
public static DocumentCategory? Find(string wizardId, string categoryId) =>
|
||||||
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authoritative upload validation (the client check is UX-only). Returns a
|
/// Authoritative upload validation (the client check is UX-only). Returns a
|
||||||
/// rejection reason, or null when the file is acceptable.
|
/// rejection reason, or null when the file is acceptable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
|
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
|
||||||
{
|
{
|
||||||
if (category is null) return "Onbekende documentcategorie.";
|
if (category is null) return "Onbekende documentcategorie.";
|
||||||
if (!category.AcceptedTypes.Contains(contentType))
|
if (!category.AcceptedTypes.Contains(contentType))
|
||||||
return $"Bestandstype niet toegestaan voor {category.Label}.";
|
return $"Bestandstype niet toegestaan voor {category.Label}.";
|
||||||
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
|
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
|
||||||
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
|
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ namespace BigRegister.Domain.Intake;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class IntakePolicy
|
public static class IntakePolicy
|
||||||
{
|
{
|
||||||
public const int ScholingThreshold = 1000;
|
public const int ScholingThreshold = 1000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,25 @@ namespace BigRegister.Domain.Registrations;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class HerregistratieRule
|
public static class HerregistratieRule
|
||||||
{
|
{
|
||||||
public const int WindowMonths = 12;
|
public const int WindowMonths = 12;
|
||||||
|
|
||||||
public static DateOnly? Deadline(Registration reg) =>
|
public static DateOnly? Deadline(Registration reg) =>
|
||||||
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
||||||
|
|
||||||
public static (bool Eligible, string? Reason) Evaluate(
|
public static (bool Eligible, string? Reason) Evaluate(
|
||||||
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
||||||
{
|
{
|
||||||
var deadline = Deadline(reg);
|
var deadline = Deadline(reg);
|
||||||
if (deadline is null)
|
if (deadline is null)
|
||||||
return (false, "Geen actieve registratie.");
|
return (false, "Geen actieve registratie.");
|
||||||
|
|
||||||
var windowStart = deadline.Value.AddMonths(-windowMonths);
|
var windowStart = deadline.Value.AddMonths(-windowMonths);
|
||||||
return today >= windowStart
|
return today >= windowStart
|
||||||
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
||||||
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
||||||
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
||||||
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
|
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>
|
/// <summary>The three states a BIG registration can be in.</summary>
|
||||||
public enum StatusTag
|
public enum StatusTag
|
||||||
{
|
{
|
||||||
Geregistreerd,
|
Geregistreerd,
|
||||||
Geschorst,
|
Geschorst,
|
||||||
Doorgehaald,
|
Doorgehaald,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -9,29 +9,29 @@ namespace BigRegister.Domain.Submissions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SubmissionRules
|
public static class SubmissionRules
|
||||||
{
|
{
|
||||||
// RULE: a manually entered diploma cannot be auto-verified.
|
// RULE: a manually entered diploma cannot be auto-verified.
|
||||||
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
||||||
diplomaHerkomst == "handmatig"
|
diplomaHerkomst == "handmatig"
|
||||||
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// RULE: an application reporting zero worked hours is rejected.
|
// RULE: an application reporting zero worked hours is rejected.
|
||||||
public static string? RejectZeroUren(int uren) =>
|
public static string? RejectZeroUren(int uren) =>
|
||||||
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
||||||
|
|
||||||
private static readonly Regex PostcodePattern =
|
private static readonly Regex PostcodePattern =
|
||||||
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
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
|
// 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).
|
// server re-validates format authoritatively (the FE check is UX-only).
|
||||||
public static string? RejectChangeRequest(string straat, string postcode)
|
public static string? RejectChangeRequest(string straat, string postcode)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
|
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
|
||||||
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
|
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string NewReference() =>
|
public static string NewReference() =>
|
||||||
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
|
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
|
||||||
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
|
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ builder.Services.AddSwaggerGen(c =>
|
|||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
builder.Services.ConfigureHttpJsonOptions(o =>
|
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||||
{
|
{
|
||||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||||
});
|
});
|
||||||
|
|
||||||
const string SpaCors = "spa";
|
const string SpaCors = "spa";
|
||||||
@@ -42,10 +42,10 @@ var api = app.MapGroup("/api/v1");
|
|||||||
|
|
||||||
api.MapGet("/dashboard-view", () =>
|
api.MapGet("/dashboard-view", () =>
|
||||||
{
|
{
|
||||||
var reg = SeedData.Registration;
|
var reg = SeedData.Registration;
|
||||||
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
||||||
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
||||||
new HerregistratieDecisionsDto(eligible, reason));
|
new HerregistratieDecisionsDto(eligible, reason));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.MapGet("/notes", () =>
|
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).
|
// and size authoritatively; stores metadata only (no file bytes / PII held).
|
||||||
api.MapPost("/uploads", async (HttpRequest request) =>
|
api.MapPost("/uploads", async (HttpRequest request) =>
|
||||||
{
|
{
|
||||||
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
||||||
var form = await request.ReadFormAsync();
|
var form = await request.ReadFormAsync();
|
||||||
var file = form.Files.GetFile("file");
|
var file = form.Files.GetFile("file");
|
||||||
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
||||||
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
||||||
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
||||||
|
|
||||||
var category = DocumentRules.Find(wizardId, categoryId);
|
var category = DocumentRules.Find(wizardId, categoryId);
|
||||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
await file.CopyToAsync(ms);
|
await file.CopyToAsync(ms);
|
||||||
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
|
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));
|
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
|
||||||
})
|
})
|
||||||
.ExcludeFromDescription();
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
@@ -118,10 +118,10 @@ api.MapPost("/uploads", async (HttpRequest request) =>
|
|||||||
// for pdf/image (browser renders it), attachment otherwise (download).
|
// for pdf/image (browser renders it), attachment otherwise (download).
|
||||||
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
|
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
|
||||||
{
|
{
|
||||||
var doc = DocumentStore.Get(documentId);
|
var doc = DocumentStore.Get(documentId);
|
||||||
if (doc is null) return Results.NotFound();
|
if (doc is null) return Results.NotFound();
|
||||||
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
|
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
|
||||||
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
|
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
|
||||||
})
|
})
|
||||||
.Produces(StatusCodes.Status200OK)
|
.Produces(StatusCodes.Status200OK)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.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.
|
// Poll-on-return: which of these client localIds have arrived at the BFF.
|
||||||
api.MapGet("/uploads/status", (string? localIds) =>
|
api.MapGet("/uploads/status", (string? localIds) =>
|
||||||
{
|
{
|
||||||
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
|
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
|
||||||
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
||||||
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
||||||
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
||||||
return new UploadStatusDto(results);
|
return new UploadStatusDto(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
||||||
api.MapDelete("/uploads/{documentId}", (string documentId) =>
|
api.MapDelete("/uploads/{documentId}", (string documentId) =>
|
||||||
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
|
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
|
||||||
{
|
{
|
||||||
DocumentStore.DeleteResult.Ok => Results.NoContent(),
|
DocumentStore.DeleteResult.Ok => Results.NoContent(),
|
||||||
DocumentStore.DeleteResult.Linked => Results.Problem(
|
DocumentStore.DeleteResult.Linked => Results.Problem(
|
||||||
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
|
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
|
||||||
statusCode: StatusCodes.Status409Conflict),
|
statusCode: StatusCodes.Status409Conflict),
|
||||||
_ => Results.NotFound(),
|
_ => Results.NotFound(),
|
||||||
})
|
})
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
@@ -164,10 +164,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
|
|||||||
|
|
||||||
api.MapGet("/applications", () =>
|
api.MapGet("/applications", () =>
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
return ApplicationStore.List(DocumentStore.DemoOwner)
|
return ApplicationStore.List(DocumentStore.DemoOwner)
|
||||||
.OrderByDescending(a => a.UpdatedAt)
|
.OrderByDescending(a => a.UpdatedAt)
|
||||||
.Select(a => a.ToSummaryDto(now)).ToList();
|
.Select(a => a.ToSummaryDto(now)).ToList();
|
||||||
});
|
});
|
||||||
|
|
||||||
api.MapGet("/applications/{id}", (string id) =>
|
api.MapGet("/applications/{id}", (string id) =>
|
||||||
@@ -179,8 +179,8 @@ api.MapGet("/applications/{id}", (string id) =>
|
|||||||
|
|
||||||
api.MapPost("/applications", (CreateApplicationRequest req) =>
|
api.MapPost("/applications", (CreateApplicationRequest req) =>
|
||||||
{
|
{
|
||||||
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
|
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
|
||||||
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
|
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
|
||||||
})
|
})
|
||||||
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
|
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
|
||||||
|
|
||||||
@@ -195,12 +195,12 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
|
|||||||
// be withdrawn (out of scope — no "intrekken").
|
// be withdrawn (out of scope — no "intrekken").
|
||||||
api.MapDelete("/applications/{id}", (string id) =>
|
api.MapDelete("/applications/{id}", (string id) =>
|
||||||
{
|
{
|
||||||
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||||
if (a is null) return Results.NotFound();
|
if (a is null) return Results.NotFound();
|
||||||
if (a.Submitted)
|
if (a.Submitted)
|
||||||
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
|
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
|
||||||
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
|
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
})
|
})
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.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.
|
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
|
||||||
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
|
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||||
if (existing is null) return Results.NotFound();
|
if (existing is null) return Results.NotFound();
|
||||||
if (existing.Submitted)
|
if (existing.Submitted)
|
||||||
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
|
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
|
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
|
||||||
(string? reject, bool autoApprovable) = existing.Type switch
|
(string? reject, bool autoApprovable) = existing.Type switch
|
||||||
{
|
{
|
||||||
"registratie" => (null, req.DiplomaHerkomst == "duo"),
|
"registratie" => (null, req.DiplomaHerkomst == "duo"),
|
||||||
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
||||||
};
|
};
|
||||||
|
|
||||||
var docs = req.Documents;
|
var docs = req.Documents;
|
||||||
if (docs is not null)
|
if (docs is not null)
|
||||||
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
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 documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||||
|
|
||||||
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
|
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
|
||||||
if (submitted is null) return Results.Conflict();
|
if (submitted is null) return Results.Conflict();
|
||||||
|
|
||||||
app.Logger.LogInformation(
|
app.Logger.LogInformation(
|
||||||
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
|
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
|
||||||
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
|
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
|
||||||
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
|
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
|
||||||
})
|
})
|
||||||
.Produces<SubmitApplicationResponse>()
|
.Produces<SubmitApplicationResponse>()
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
@@ -245,15 +245,15 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
|||||||
|
|
||||||
api.MapGet("/brief", () =>
|
api.MapGet("/brief", () =>
|
||||||
{
|
{
|
||||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||||
})
|
})
|
||||||
.Produces<BriefViewDto>();
|
.Produces<BriefViewDto>();
|
||||||
|
|
||||||
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (_, isDrafter) = BriefRole(ctx);
|
var (_, isDrafter) = BriefRole(ctx);
|
||||||
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
@@ -261,10 +261,10 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
|||||||
|
|
||||||
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (_, isDrafter) = BriefRole(ctx);
|
var (_, isDrafter) = BriefRole(ctx);
|
||||||
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||||
LogBrief("submit", r);
|
LogBrief("submit", r);
|
||||||
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
||||||
})
|
})
|
||||||
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefDto>()
|
||||||
@@ -273,10 +273,10 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
|
|||||||
|
|
||||||
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (acting, _) = BriefRole(ctx);
|
var (acting, _) = BriefRole(ctx);
|
||||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
||||||
LogBrief("approve", r);
|
LogBrief("approve", r);
|
||||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
@@ -284,10 +284,10 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
|
|||||||
|
|
||||||
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (acting, _) = BriefRole(ctx);
|
var (acting, _) = BriefRole(ctx);
|
||||||
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
|
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
|
||||||
LogBrief("reject", r);
|
LogBrief("reject", r);
|
||||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
@@ -295,20 +295,20 @@ api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
|||||||
|
|
||||||
api.MapPost("/brief/send", () =>
|
api.MapPost("/brief/send", () =>
|
||||||
{
|
{
|
||||||
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
||||||
// port); the backend only guards the approved→sent transition.
|
// port); the backend only guards the approved→sent transition.
|
||||||
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
||||||
LogBrief("send", r);
|
LogBrief("send", r);
|
||||||
return BriefResult(r, "Versturen kan niet in deze status.");
|
return BriefResult(r, "Versturen kan niet in deze status.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefDto>()
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
api.MapPost("/brief/reset", () =>
|
api.MapPost("/brief/reset", () =>
|
||||||
{
|
{
|
||||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||||
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
||||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||||
})
|
})
|
||||||
.WithName("briefReset")
|
.WithName("briefReset")
|
||||||
.Produces<BriefViewDto>();
|
.Produces<BriefViewDto>();
|
||||||
@@ -323,15 +323,15 @@ static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
|||||||
// else (default) acts as the drafter.
|
// else (default) acts as the drafter.
|
||||||
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
|
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
|
||||||
{
|
{
|
||||||
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
||||||
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
||||||
}
|
}
|
||||||
|
|
||||||
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
||||||
{
|
{
|
||||||
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
|
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
|
||||||
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
|
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),
|
_ => 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) =>
|
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).
|
// real system ships this to structured logging / an audit store).
|
||||||
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
|
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)
|
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||||
? v.ToString()
|
? v.ToString()
|
||||||
: "none";
|
: "none";
|
||||||
|
|
||||||
if (reject is not null)
|
if (reject is not null)
|
||||||
{
|
{
|
||||||
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
||||||
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (documents is not null)
|
if (documents is not null)
|
||||||
{
|
{
|
||||||
// Link digital documents (blocks later user delete) and record post-delivery
|
// Link digital documents (blocks later user delete) and record post-delivery
|
||||||
// intent so a caseworker knows to expect the physical document.
|
// 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!));
|
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"))
|
foreach (var d in documents.Where(d => d.Channel == "post"))
|
||||||
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
||||||
}
|
}
|
||||||
|
|
||||||
var reference = SubmissionRules.NewReference();
|
var reference = SubmissionRules.NewReference();
|
||||||
app.Logger.LogInformation(
|
app.Logger.LogInformation(
|
||||||
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
||||||
kind, reference, cid, DateTimeOffset.UtcNow);
|
kind, reference, cid, DateTimeOffset.UtcNow);
|
||||||
return Results.Ok(new ReferentieResponse(reference));
|
return Results.Ok(new ReferentieResponse(reference));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||||
|
|||||||
@@ -8,129 +8,135 @@ namespace BigRegister.Tests;
|
|||||||
|
|
||||||
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
private async Task<ApplicationDetailDto> Create(string type = "registratie")
|
private async Task<ApplicationDetailDto> Create(string type = "registratie")
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
|
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
|
||||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||||
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<List<ApplicationSummaryDto>?> List() =>
|
private Task<List<ApplicationSummaryDto>?> List() =>
|
||||||
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
|
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
|
||||||
|
|
||||||
// --- Lifecycle over HTTP ---
|
// --- Lifecycle over HTTP ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_then_list_shows_a_concept_with_step_progress()
|
public async Task Create_then_list_shows_a_concept_with_step_progress()
|
||||||
{
|
{
|
||||||
var a = await Create();
|
var a = await Create();
|
||||||
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||||
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
|
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
|
||||||
|
|
||||||
var mine = (await List())!.Single(x => x.Id == a.Id);
|
var mine = (await List())!.Single(x => x.Id == a.Id);
|
||||||
Assert.Equal("Concept", mine.Status.Tag);
|
Assert.Equal("Concept", mine.Status.Tag);
|
||||||
Assert.Equal(1, mine.Status.StepIndex);
|
Assert.Equal(1, mine.Status.StepIndex);
|
||||||
Assert.Equal(4, mine.Status.StepCount);
|
Assert.Equal(4, mine.Status.StepCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Draft_sync_is_readable_back_from_detail()
|
public async Task Draft_sync_is_readable_back_from_detail()
|
||||||
{
|
{
|
||||||
var a = await Create();
|
var a = await Create();
|
||||||
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||||
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
|
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
|
||||||
|
|
||||||
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
|
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
|
||||||
Assert.NotNull(detail!.Draft);
|
Assert.NotNull(detail!.Draft);
|
||||||
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
|
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
|
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
|
||||||
{
|
{
|
||||||
var a = await Create("registratie");
|
var a = await Create("registratie");
|
||||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||||
Assert.StartsWith("BIG-2026-", body.Referentie);
|
Assert.StartsWith("BIG-2026-", body.Referentie);
|
||||||
Assert.Equal("InBehandeling", body.Status.Tag);
|
Assert.Equal("InBehandeling", body.Status.Tag);
|
||||||
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
|
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
|
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
|
||||||
{
|
{
|
||||||
var a = await Create("registratie");
|
var a = await Create("registratie");
|
||||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
||||||
res.EnsureSuccessStatusCode(); // no longer a 422
|
res.EnsureSuccessStatusCode(); // no longer a 422
|
||||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||||
Assert.Equal("InBehandeling", body.Status.Tag);
|
Assert.Equal("InBehandeling", body.Status.Tag);
|
||||||
Assert.True(body.Status.Manual);
|
Assert.True(body.Status.Manual);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
|
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
|
||||||
{
|
{
|
||||||
var a = await Create("herregistratie");
|
var a = await Create("herregistratie");
|
||||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
|
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
|
||||||
res.EnsureSuccessStatusCode(); // the submission is accepted...
|
res.EnsureSuccessStatusCode(); // the submission is accepted...
|
||||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||||
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
|
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
|
||||||
Assert.NotNull(body.Status.Reden);
|
Assert.NotNull(body.Status.Reden);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submitting_twice_conflicts()
|
public async Task Submitting_twice_conflicts()
|
||||||
{
|
{
|
||||||
var a = await Create("registratie");
|
var a = await Create("registratie");
|
||||||
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
||||||
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
||||||
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cancel_concept_removes_it()
|
public async Task Cancel_concept_removes_it()
|
||||||
{
|
{
|
||||||
var a = await Create();
|
var a = await Create();
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cancel_submitted_aanvraag_conflicts()
|
public async Task Cancel_submitted_aanvraag_conflicts()
|
||||||
{
|
{
|
||||||
var a = await Create("registratie");
|
var a = await Create("registratie");
|
||||||
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
||||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
||||||
|
|
||||||
private static Aanvraag Accepted(bool autoApprovable) => new()
|
private static Aanvraag Accepted(bool autoApprovable) => new()
|
||||||
{
|
{
|
||||||
Id = "x", Type = "registratie", Owner = "test",
|
Id = "x",
|
||||||
Submitted = true, AutoApprovable = autoApprovable, Referentie = "BIG-2026-1",
|
Type = "registratie",
|
||||||
SubmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
Owner = "test",
|
||||||
};
|
Submitted = true,
|
||||||
|
AutoApprovable = autoApprovable,
|
||||||
|
Referentie = "BIG-2026-1",
|
||||||
|
SubmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
|
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
|
||||||
{
|
{
|
||||||
var a = Accepted(autoApprovable: true);
|
var a = Accepted(autoApprovable: true);
|
||||||
var t0 = a.SubmittedAt!.Value;
|
var t0 = a.SubmittedAt!.Value;
|
||||||
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
|
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
|
||||||
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
|
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manual_case_never_auto_advances()
|
public void Manual_case_never_auto_advances()
|
||||||
{
|
{
|
||||||
var a = Accepted(autoApprovable: false);
|
var a = Accepted(autoApprovable: false);
|
||||||
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
|
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
|
||||||
var status = a.ToStatusDto(far);
|
var status = a.ToStatusDto(far);
|
||||||
Assert.Equal("InBehandeling", status.Tag);
|
Assert.Equal("InBehandeling", status.Tag);
|
||||||
Assert.True(status.Manual);
|
Assert.True(status.Manual);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,150 +13,150 @@ namespace BigRegister.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
private static LetterBlockDto FreeText(string id) =>
|
private static LetterBlockDto FreeText(string id) =>
|
||||||
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
||||||
|
|
||||||
// Fill every REQUIRED section with a block so submit is allowed.
|
// Fill every REQUIRED section with a block so submit is allowed.
|
||||||
private static SaveBriefRequest FilledFrom(BriefDto brief)
|
private static SaveBriefRequest FilledFrom(BriefDto brief)
|
||||||
{
|
{
|
||||||
var i = 0;
|
var i = 0;
|
||||||
var sections = brief.Sections
|
var sections = brief.Sections
|
||||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
|
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
|
||||||
.ToList();
|
.ToList();
|
||||||
return new SaveBriefRequest(sections);
|
return new SaveBriefRequest(sections);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<BriefDto> Get()
|
private async Task<BriefDto> Get()
|
||||||
{
|
{
|
||||||
BriefStore.Reset();
|
BriefStore.Reset();
|
||||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
return view!.Brief;
|
return view!.Brief;
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
||||||
{
|
{
|
||||||
var req = new HttpRequestMessage(HttpMethod.Post, path);
|
var req = new HttpRequestMessage(HttpMethod.Post, path);
|
||||||
if (role is not null) req.Headers.Add("X-Role", role);
|
if (role is not null) req.Headers.Add("X-Role", role);
|
||||||
if (body is not null) req.Content = JsonContent.Create(body);
|
if (body is not null) req.Content = JsonContent.Create(body);
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
||||||
{
|
{
|
||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
Assert.Equal("draft", brief.Status.Tag);
|
Assert.Equal("draft", brief.Status.Tag);
|
||||||
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||||
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
||||||
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
|
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||||
Assert.True(aanhef.Locked);
|
Assert.True(aanhef.Locked);
|
||||||
Assert.NotEmpty(aanhef.Blocks);
|
Assert.NotEmpty(aanhef.Blocks);
|
||||||
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
|
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
|
||||||
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
|
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
|
||||||
Assert.False(kern.Locked);
|
Assert.False(kern.Locked);
|
||||||
Assert.Empty(kern.Blocks);
|
Assert.Empty(kern.Blocks);
|
||||||
|
|
||||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||||
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
||||||
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Save_is_drafter_only()
|
public async Task Save_is_drafter_only()
|
||||||
{
|
{
|
||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
var save = FilledFrom(brief);
|
var save = FilledFrom(brief);
|
||||||
|
|
||||||
var approver = Post("/api/v1/brief", role: "approver");
|
var approver = Post("/api/v1/brief", role: "approver");
|
||||||
approver.Method = HttpMethod.Put;
|
approver.Method = HttpMethod.Put;
|
||||||
approver.Content = JsonContent.Create(save);
|
approver.Content = JsonContent.Create(save);
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
||||||
{
|
{
|
||||||
await Get();
|
await Get();
|
||||||
// Nothing filled yet → required sections empty → 409.
|
// Nothing filled yet → required sections empty → 409.
|
||||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
||||||
|
|
||||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
||||||
Assert.Equal("submitted", submitted!.Status.Tag);
|
Assert.Equal("submitted", submitted!.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
||||||
{
|
{
|
||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
// drafter role approving own letter → 403
|
// drafter role approving own letter → 403
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
||||||
|
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
||||||
{
|
{
|
||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
var rejected = await (await _client.SendAsync(
|
var rejected = await (await _client.SendAsync(
|
||||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
||||||
Assert.Equal("rejected", rejected!.Status.Tag);
|
Assert.Equal("rejected", rejected!.Status.Tag);
|
||||||
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
||||||
|
|
||||||
// A drafter save on a rejected letter reopens it to draft.
|
// A drafter save on a rejected letter reopens it to draft.
|
||||||
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
|
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
|
||||||
Assert.Equal("draft", reopened!.Status.Tag);
|
Assert.Equal("draft", reopened!.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Send_only_from_approved()
|
public async Task Send_only_from_approved()
|
||||||
{
|
{
|
||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
// submitted (not approved) → send 409
|
// submitted (not approved) → send 409
|
||||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
||||||
|
|
||||||
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
||||||
{
|
{
|
||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
// Advance out of draft so the reset back to draft is observable.
|
// Advance out of draft so the reset back to draft is observable.
|
||||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
Assert.Equal("draft", view!.Brief.Status.Tag);
|
Assert.Equal("draft", view!.Brief.Status.Tag);
|
||||||
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
|
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||||
Assert.True(aanhef.Locked);
|
Assert.True(aanhef.Locked);
|
||||||
Assert.NotEmpty(aanhef.Blocks);
|
Assert.NotEmpty(aanhef.Blocks);
|
||||||
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
|
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,223 +9,223 @@ namespace BigRegister.Tests;
|
|||||||
|
|
||||||
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DashboardView_computes_eligibility_decision()
|
public async Task DashboardView_computes_eligibility_decision()
|
||||||
{
|
{
|
||||||
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
|
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
|
||||||
Assert.NotNull(dto);
|
Assert.NotNull(dto);
|
||||||
Assert.Equal("19012345601", dto.Registration.BigNummer);
|
Assert.Equal("19012345601", dto.Registration.BigNummer);
|
||||||
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
|
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
|
||||||
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
|
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
|
||||||
Assert.True(dto.Decisions.EligibleForHerregistratie);
|
Assert.True(dto.Decisions.EligibleForHerregistratie);
|
||||||
Assert.NotNull(dto.Decisions.HerregistratieReason);
|
Assert.NotNull(dto.Decisions.HerregistratieReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Notes_returns_seeded_aantekeningen()
|
public async Task Notes_returns_seeded_aantekeningen()
|
||||||
{
|
{
|
||||||
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
|
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
|
||||||
Assert.Equal(3, notes!.Count);
|
Assert.Equal(3, notes!.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Brp_returns_address()
|
public async Task Brp_returns_address()
|
||||||
{
|
{
|
||||||
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
|
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
|
||||||
Assert.True(dto!.Gevonden);
|
Assert.True(dto!.Gevonden);
|
||||||
Assert.Equal("2514 EA", dto.Adres!.Postcode);
|
Assert.Equal("2514 EA", dto.Adres!.Postcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
|
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
|
||||||
{
|
{
|
||||||
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
|
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
|
||||||
Assert.NotNull(dto);
|
Assert.NotNull(dto);
|
||||||
|
|
||||||
var english = dto.Diplomas.Single(d => d.Id == "d2");
|
var english = dto.Diplomas.Single(d => d.Id == "d2");
|
||||||
Assert.Equal("Arts", english.Beroep);
|
Assert.Equal("Arts", english.Beroep);
|
||||||
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
|
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
|
||||||
|
|
||||||
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
|
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
|
||||||
Assert.Empty(dutch.PolicyQuestions);
|
Assert.Empty(dutch.PolicyQuestions);
|
||||||
|
|
||||||
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
|
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
|
||||||
// which the same lookup provides (maximal question set + declarable professions).
|
// which the same lookup provides (maximal question set + declarable professions).
|
||||||
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
|
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
|
||||||
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
|
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
|
||||||
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
|
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IntakePolicy_returns_scholing_threshold()
|
public async Task IntakePolicy_returns_scholing_threshold()
|
||||||
{
|
{
|
||||||
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
|
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
|
||||||
Assert.Equal(1000, dto!.ScholingThreshold);
|
Assert.Equal(1000, dto!.ScholingThreshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Registration_with_duo_diploma_succeeds()
|
public async Task Registration_with_duo_diploma_succeeds()
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
|
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
|
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
|
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
|
||||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||||
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/api/v1/intakes")]
|
[InlineData("/api/v1/intakes")]
|
||||||
[InlineData("/api/v1/herregistraties")]
|
[InlineData("/api/v1/herregistraties")]
|
||||||
public async Task Zero_hours_submission_is_rejected(string route)
|
public async Task Zero_hours_submission_is_rejected(string route)
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
|
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
|
||||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/api/v1/intakes")]
|
[InlineData("/api/v1/intakes")]
|
||||||
[InlineData("/api/v1/herregistraties")]
|
[InlineData("/api/v1/herregistraties")]
|
||||||
public async Task Worked_hours_submission_succeeds(string route)
|
public async Task Worked_hours_submission_succeeds(string route)
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Change_request_with_valid_address_succeeds()
|
public async Task Change_request_with_valid_address_succeeds()
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
||||||
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
|
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Change_request_with_bad_postcode_is_rejected()
|
public async Task Change_request_with_bad_postcode_is_rejected()
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
||||||
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
|
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
|
||||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Health_endpoint_is_ok()
|
public async Task Health_endpoint_is_ok()
|
||||||
{
|
{
|
||||||
var res = await _client.GetAsync("/health");
|
var res = await _client.GetAsync("/health");
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Document upload ---
|
// --- Document upload ---
|
||||||
|
|
||||||
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
||||||
{
|
{
|
||||||
var content = new MultipartFormDataContent();
|
var content = new MultipartFormDataContent();
|
||||||
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
||||||
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||||
content.Add(file, "file", fileName);
|
content.Add(file, "file", fileName);
|
||||||
content.Add(new StringContent(categoryId), "categoryId");
|
content.Add(new StringContent(categoryId), "categoryId");
|
||||||
content.Add(new StringContent(localId), "localId");
|
content.Add(new StringContent(localId), "localId");
|
||||||
content.Add(new StringContent(wizardId), "wizardId");
|
content.Add(new StringContent(wizardId), "wizardId");
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
|
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
|
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
|
||||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||||
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Categories_are_server_owned_config()
|
public async Task Categories_are_server_owned_config()
|
||||||
{
|
{
|
||||||
// A manual diploma requires a diploma upload; identiteit is always required.
|
// A manual diploma requires a diploma upload; identiteit is always required.
|
||||||
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
|
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
|
||||||
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
|
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
|
||||||
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
|
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Upload_then_status_reports_complete_for_known_localId()
|
public async Task Upload_then_status_reports_complete_for_known_localId()
|
||||||
{
|
{
|
||||||
var localId = Guid.NewGuid().ToString();
|
var localId = Guid.NewGuid().ToString();
|
||||||
var doc = await Upload(localId);
|
var doc = await Upload(localId);
|
||||||
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
|
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
|
||||||
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
|
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
|
||||||
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
|
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
|
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
|
||||||
{
|
{
|
||||||
var doc = await Upload(Guid.NewGuid().ToString());
|
var doc = await Upload(Guid.NewGuid().ToString());
|
||||||
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
|
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
|
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
|
||||||
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
|
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
|
||||||
// pdf/image → inline (no attachment disposition) so the browser previews it
|
// pdf/image → inline (no attachment disposition) so the browser previews it
|
||||||
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
|
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Upload_content_404_for_unknown_document()
|
public async Task Upload_content_404_for_unknown_document()
|
||||||
{
|
{
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Upload_rejects_wrong_type()
|
public async Task Upload_rejects_wrong_type()
|
||||||
{
|
{
|
||||||
var res = await _client.PostAsync("/api/v1/uploads",
|
var res = await _client.PostAsync("/api/v1/uploads",
|
||||||
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
|
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_delete_succeeds_then_404()
|
public async Task User_delete_succeeds_then_404()
|
||||||
{
|
{
|
||||||
var doc = await Upload(Guid.NewGuid().ToString());
|
var doc = await Upload(Guid.NewGuid().ToString());
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_delete_blocked_with_409_once_linked_to_submission()
|
public async Task User_delete_blocked_with_409_once_linked_to_submission()
|
||||||
{
|
{
|
||||||
var doc = await Upload(Guid.NewGuid().ToString());
|
var doc = await Upload(Guid.NewGuid().ToString());
|
||||||
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
|
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
|
||||||
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
|
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
|
||||||
submit.EnsureSuccessStatusCode();
|
submit.EnsureSuccessStatusCode();
|
||||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Admin_delete_requires_admin_role()
|
public async Task Admin_delete_requires_admin_role()
|
||||||
{
|
{
|
||||||
var doc = await Upload(Guid.NewGuid().ToString());
|
var doc = await Upload(Guid.NewGuid().ToString());
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
|
||||||
|
|
||||||
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
|
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
|
||||||
req.Headers.Add("X-Admin", "true");
|
req.Headers.Add("X-Admin", "true");
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Audit_log_records_upload_and_delete_metadata_only()
|
public async Task Audit_log_records_upload_and_delete_metadata_only()
|
||||||
{
|
{
|
||||||
var doc = await Upload(Guid.NewGuid().ToString());
|
var doc = await Upload(Guid.NewGuid().ToString());
|
||||||
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
|
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
|
||||||
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
|
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
|
||||||
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
|
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,174 +7,174 @@ namespace BigRegister.Tests;
|
|||||||
|
|
||||||
public class DocumentRuleTests
|
public class DocumentRuleTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Rejects_unknown_category() =>
|
public void Rejects_unknown_category() =>
|
||||||
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
|
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Rejects_disallowed_type()
|
public void Rejects_disallowed_type()
|
||||||
{
|
{
|
||||||
var c = DocumentRules.Find("registratie", "diploma");
|
var c = DocumentRules.Find("registratie", "diploma");
|
||||||
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
|
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Rejects_oversized_file()
|
public void Rejects_oversized_file()
|
||||||
{
|
{
|
||||||
var c = DocumentRules.Find("registratie", "diploma");
|
var c = DocumentRules.Find("registratie", "diploma");
|
||||||
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
|
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Accepts_valid_file()
|
public void Accepts_valid_file()
|
||||||
{
|
{
|
||||||
var c = DocumentRules.Find("registratie", "diploma");
|
var c = DocumentRules.Find("registratie", "diploma");
|
||||||
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
|
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
|
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
|
||||||
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
|
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
|
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
|
||||||
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
|
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manual_diploma_needs_a_diploma_upload() =>
|
public void Manual_diploma_needs_a_diploma_upload() =>
|
||||||
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
|
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Duo_diploma_skips_diploma_upload() =>
|
public void Duo_diploma_skips_diploma_upload() =>
|
||||||
Assert.DoesNotContain("diploma", Ids("duo", null));
|
Assert.DoesNotContain("diploma", Ids("duo", null));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
|
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
|
||||||
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
|
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
|
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
|
||||||
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
|
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
|
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
|
||||||
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
|
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public class HerregistratieRuleTests
|
public class HerregistratieRuleTests
|
||||||
{
|
{
|
||||||
private static Registration Active(DateOnly deadline) => new(
|
private static Registration Active(DateOnly deadline) => new(
|
||||||
"19012345601", "Test", "Arts",
|
"19012345601", "Test", "Arts",
|
||||||
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Eligible_within_window()
|
public void Eligible_within_window()
|
||||||
{
|
{
|
||||||
var (eligible, reason) = HerregistratieRule.Evaluate(
|
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||||
Assert.True(eligible);
|
Assert.True(eligible);
|
||||||
Assert.Contains("12 maanden", reason);
|
Assert.Contains("12 maanden", reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Not_eligible_before_window()
|
public void Not_eligible_before_window()
|
||||||
{
|
{
|
||||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||||
Assert.False(eligible);
|
Assert.False(eligible);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Eligible_on_window_boundary()
|
public void Eligible_on_window_boundary()
|
||||||
{
|
{
|
||||||
// window opens exactly 12 months before the deadline
|
// window opens exactly 12 months before the deadline
|
||||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||||
Assert.True(eligible);
|
Assert.True(eligible);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Suspended_is_not_eligible()
|
public void Suspended_is_not_eligible()
|
||||||
|
{
|
||||||
|
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||||
{
|
{
|
||||||
var reg = Active(new DateOnly(2027, 3, 1)) with
|
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||||
{
|
};
|
||||||
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||||
};
|
Assert.False(eligible);
|
||||||
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
}
|
||||||
Assert.False(eligible);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Status_consistency_invariant()
|
public void Status_consistency_invariant()
|
||||||
{
|
{
|
||||||
Assert.True(HerregistratieRule.IsStatusConsistent(
|
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||||
Assert.False(HerregistratieRule.IsStatusConsistent(
|
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||||
new RegistrationStatus(StatusTag.Geregistreerd)));
|
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DiplomaRuleTests
|
public class DiplomaRuleTests
|
||||||
{
|
{
|
||||||
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||||
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("geneeskunde", "Arts")]
|
[InlineData("geneeskunde", "Arts")]
|
||||||
[InlineData("verpleegkunde", "Verpleegkundige")]
|
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||||
[InlineData("onbekend-programma", "Onbekend")]
|
[InlineData("onbekend-programma", "Onbekend")]
|
||||||
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||||
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void English_diploma_requires_dutch_proficiency()
|
public void English_diploma_requires_dutch_proficiency()
|
||||||
{
|
{
|
||||||
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||||
Assert.Single(questions);
|
Assert.Single(questions);
|
||||||
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dutch_diploma_has_no_policy_questions() =>
|
public void Dutch_diploma_has_no_policy_questions() =>
|
||||||
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manual_diploma_gets_maximal_set()
|
public void Manual_diploma_gets_maximal_set()
|
||||||
{
|
{
|
||||||
var questions = DiplomaRules.ManualQuestions();
|
var questions = DiplomaRules.ManualQuestions();
|
||||||
Assert.Equal(3, questions.Count);
|
Assert.Equal(3, questions.Count);
|
||||||
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||||
questions.Select(q => q.Id));
|
questions.Select(q => q.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manual_professions_match_known_programs() =>
|
public void Manual_professions_match_known_programs() =>
|
||||||
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||||
DiplomaRules.ManualProfessions());
|
DiplomaRules.ManualProfessions());
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SubmissionRuleTests
|
public class SubmissionRuleTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manual_diploma_is_rejected() =>
|
public void Manual_diploma_is_rejected() =>
|
||||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Duo_diploma_is_accepted() =>
|
public void Duo_diploma_is_accepted() =>
|
||||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Zero_hours_is_rejected() =>
|
public void Zero_hours_is_rejected() =>
|
||||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Worked_hours_are_accepted() =>
|
public void Worked_hours_are_accepted() =>
|
||||||
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
|
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
|
||||||
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
|
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
|
||||||
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
|
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
|
||||||
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
|
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
|
||||||
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
|
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user