refactor(backend): delete dead legacy endpoints, make domain types unions (WP-72 + WP-73)
Two work packages in one commit because both edit Program.cs and splitting
them would leave a commit that does not build.
WP-72 — deletes POST /api/v1/intakes and /herregistraties. Both were dead
from the UI (the wizard submits via /applications/{id}/submit) and strictly
less capable: they minted a bare reference and wrote no Aanvraag, made no
ZGW call, and did no document-ownership check. The shared Submit(...) helper
survives — /registrations and /change-requests still use it. WP-69 hardened
/intakes with a 400 last session; removing the surface is the stronger fix,
and WP-69's /applications/{id}/submit enforcement is untouched.
WP-73 — RegistrationStatus becomes an abstract record with three sealed
variants behind a private base ctor, so only Geregistreerd carries a
herregistratie deadline and reden is required on Geschorst/Doorgehaald
(matching the FE union, which was already right). HerregistratieRule
.IsStatusConsistent and its test are deleted: the type now guarantees what
the runtime check was for, and the test could no longer construct the
illegal state it existed to catch.
Aanvraag splits into a Concept | Submitted | Decided union with the EF row
demoted to AanvraagEntity behind a two-way mapper. Submitted carries a
non-null Referentie and SubmittedAt, and Decided.Afgewezen/MeerInfoGevraagd
require a Toelichting — so the five Referentie! null-forgiving derefs in
StatusAt are gone, not merely suppressed. IZaakSource.CreateZaak narrows to
Aanvraag.Submitted, removing the same class of deref in both zaak sources.
Draft is now cleared on submit rather than lingering: ApplicationStore's
doc-comment claimed "Concept only" but Submit never cleared it. Verified
nothing reads a submitted aanvraag's draft (draft-sync's applyResume only
resumes unsubmitted wizards), so the comment is now true instead of
aspirational.
No migration, no schema change, no wire change — RegistrationStatusDto and
the application DTOs are byte-identical, confirmed against a live swagger.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,12 +76,6 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
|
||||
// stay on the client). ponytail: a real submit would carry the full application.
|
||||
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
|
||||
// AanvullendeScholing/ScholingPunten (WP-69): the wizard's scholing answer, re-validated
|
||||
// server-side as the authority by IntakePolicy.RejectIncompleteScholing. Named
|
||||
// ScholingPunten (not Punten) — the sibling SubmitApplicationRequest is shared by all three
|
||||
// wizard types and the herregistratie wizard has its own unrelated `punten`.
|
||||
public sealed record IntakeRequest(int Uren, bool? AanvullendeScholing = null, int? ScholingPunten = null);
|
||||
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
public sealed record ChangeRequestRequest(string Telefoon);
|
||||
|
||||
// Authz/PII-reveal audit row (WP-41) — data-minimised, no PII (see AuthzAuditEntry).
|
||||
@@ -125,8 +119,8 @@ public sealed record DraftSyncRequest(
|
||||
IReadOnlyList<string>? DocumentIds = null);
|
||||
|
||||
// Submit carries only the fields the server re-validates per wizard type.
|
||||
// AanvullendeScholing/ScholingPunten (WP-69) — see IntakeRequest; intake-typed aanvragen
|
||||
// only (gated by IntakePolicy.RejectIncompleteScholing's caller), null for the others.
|
||||
// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by
|
||||
// IntakePolicy.RejectIncompleteScholing's caller), null for the others.
|
||||
public sealed record SubmitApplicationRequest(
|
||||
string? DiplomaHerkomst = null, int? Uren = null,
|
||||
IReadOnlyList<DocumentRefDto>? Documents = null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
@@ -12,12 +13,13 @@ public static class Mappers
|
||||
{
|
||||
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
|
||||
|
||||
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
|
||||
Tag: s.Tag.ToString(),
|
||||
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
||||
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
||||
Reden: s.Reden,
|
||||
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
||||
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => s switch
|
||||
{
|
||||
RegistrationStatus.Geregistreerd g => new(s.Tag.ToString(), HerregistratieDatum: D(g.HerregistratieDatum)),
|
||||
RegistrationStatus.Geschorst g => new(s.Tag.ToString(), GeschorstTot: D(g.GeschorstTot), Reden: g.Reden),
|
||||
RegistrationStatus.Doorgehaald d => new(s.Tag.ToString(), DoorgehaaldOp: D(d.DoorgehaaldOp), Reden: d.Reden),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s), s, "Unknown RegistrationStatus variant"),
|
||||
};
|
||||
|
||||
public static RegistrationDto ToDto(this Registration r) => new(
|
||||
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
|
||||
@@ -45,19 +47,33 @@ public static class Mappers
|
||||
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
|
||||
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
|
||||
|
||||
// Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line
|
||||
// projection of that domain method onto the wire DTO (WP-68 F3).
|
||||
// Aanvraag status is COMPUTED ON READ (see the StatusAt extension, Data/AanvraagMapper.cs) —
|
||||
// this is now a one-line projection of that onto the wire DTO (WP-68 F3, WP-73).
|
||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
|
||||
|
||||
/// <summary>SubmittedAt only exists once Submitted/Decided (WP-73) — null for a Concept,
|
||||
/// same as the wire DTO's own nullable field.</summary>
|
||||
private static string? SubmittedAtOf(Aanvraag a) => a switch
|
||||
{
|
||||
Aanvraag.Concept => null,
|
||||
Aanvraag.Submitted s => s.SubmittedAt.ToString("o"),
|
||||
Aanvraag.Decided d => d.SubmittedAt.ToString("o"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Draft only exists pre-submission (WP-73) — null once Submitted/Decided (nothing
|
||||
/// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary>
|
||||
private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null;
|
||||
|
||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
|
||||
|
||||
/// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null).
|
||||
public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
|
||||
a.ToSummaryDto(now) with { Owner = a.Owner };
|
||||
|
||||
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||
a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using BigRegister.Domain.Applications;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The two-way seam between <see cref="AanvraagEntity"/> (the EF-mapped persistence row —
|
||||
/// mutable, no invariants of its own, exactly the shape SQLite needs) and <see cref="Aanvraag"/>
|
||||
/// (the closed Concept/Submitted/Decided domain union, WP-73). <see cref="ToDomain"/> is the
|
||||
/// read half: it reconstructs whichever variant a row's stored fields describe, going through
|
||||
/// that variant's own constructor/required members, so a row that doesn't actually describe a
|
||||
/// legal aanvraag throws here rather than downstream. <see cref="ApplyTo"/>/<see cref="ToEntity"/>
|
||||
/// are the write half, used by <see cref="ApplicationStore"/>'s writers (and test fixtures, e.g.
|
||||
/// <c>Builders/AanvraagBuilder.cs</c>) to flush a freshly-constructed domain value onto a row
|
||||
/// before <c>SaveChanges</c>.
|
||||
/// </summary>
|
||||
public static class AanvraagMapper
|
||||
{
|
||||
public static Aanvraag ToDomain(this AanvraagEntity row)
|
||||
{
|
||||
if (!row.Submitted)
|
||||
return new Aanvraag.Concept(row.StepIndex, row.StepCount)
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Draft = row.Draft,
|
||||
};
|
||||
|
||||
var referentie = row.Referentie
|
||||
?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no Referentie.");
|
||||
var submittedAt = row.SubmittedAt
|
||||
?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no SubmittedAt.");
|
||||
|
||||
// Reden wins over BesluitStatus — matches the pre-WP-73 StatusAt's own priority. In
|
||||
// practice a row never carries both (BeoordelingRules.CanDecide already refuses a besluit
|
||||
// once Reden's auto-reject makes the projected status Afgewezen), but if it somehow did,
|
||||
// the auto-reject at submission time is authoritative.
|
||||
if (row.Reden is null && row.BesluitStatus is { } besluit)
|
||||
return besluit switch
|
||||
{
|
||||
Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
},
|
||||
Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
Toelichting = row.BesluitToelichting
|
||||
?? throw new InvalidOperationException($"Afgewezen aanvraag {row.Id} has no toelichting."),
|
||||
},
|
||||
Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
Toelichting = row.BesluitToelichting
|
||||
?? throw new InvalidOperationException($"MeerInfoGevraagd aanvraag {row.Id} has no toelichting."),
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown besluit {besluit}."),
|
||||
};
|
||||
|
||||
return new Aanvraag.Submitted
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
AutoApprovable = row.AutoApprovable,
|
||||
Reden = row.Reden,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Flushes a domain value onto an already-tracked row — everything but identity
|
||||
/// (Id/Type/Owner) and CreatedAt, which never change once a row exists. Used by
|
||||
/// <see cref="ApplicationStore"/>'s SyncDraft/Submit/RecordBesluit, each of which already
|
||||
/// <c>Find()</c>ed the row this applies to.</summary>
|
||||
public static void ApplyTo(this Aanvraag a, AanvraagEntity row)
|
||||
{
|
||||
row.DocumentIds = a.DocumentIds.ToList();
|
||||
row.UpdatedAt = a.UpdatedAt;
|
||||
row.ZaakUrl = a.ZaakUrl;
|
||||
row.ZgwError = a.ZgwError;
|
||||
|
||||
switch (a)
|
||||
{
|
||||
case Aanvraag.Concept c:
|
||||
row.Draft = c.Draft;
|
||||
row.StepIndex = c.StepIndex;
|
||||
row.StepCount = c.StepCount;
|
||||
row.Submitted = false;
|
||||
row.Referentie = null;
|
||||
row.SubmittedAt = null;
|
||||
row.AutoApprovable = false;
|
||||
row.Reden = null;
|
||||
row.BesluitStatus = null;
|
||||
row.BesluitToelichting = null;
|
||||
break;
|
||||
|
||||
case Aanvraag.Submitted s:
|
||||
// Submitted ⇒ !Draft (WP-73's Draft decision) — nothing reads a submitted aanvraag's
|
||||
// draft (registratie/application/draft-sync.ts only ever resumes a still-Concept
|
||||
// wizard), so this is now actually true rather than the aspirational doc-comment it
|
||||
// used to be.
|
||||
row.Draft = null;
|
||||
row.Submitted = true;
|
||||
row.Referentie = s.Referentie;
|
||||
row.SubmittedAt = s.SubmittedAt;
|
||||
row.AutoApprovable = s.AutoApprovable;
|
||||
row.Reden = s.Reden;
|
||||
row.BesluitStatus = null;
|
||||
row.BesluitToelichting = null;
|
||||
break;
|
||||
|
||||
case Aanvraag.Decided d:
|
||||
row.Draft = null;
|
||||
row.Submitted = true;
|
||||
row.Referentie = d.Referentie;
|
||||
row.SubmittedAt = d.SubmittedAt;
|
||||
// AutoApprovable/Reden are left as whatever the row already carries from its earlier
|
||||
// Submitted stage: Decided doesn't model them (StatusAt never consults them once a
|
||||
// besluit is recorded — its Decided branches are checked first), and a fresh row built
|
||||
// straight from a Decided fixture with no prior Submitted stage (see ToEntity) simply
|
||||
// keeps their type defaults (false/null), which is equally harmless for the same reason.
|
||||
row.BesluitStatus = d switch
|
||||
{
|
||||
Aanvraag.Decided.Goedgekeurd => Besluit.Goedkeuren,
|
||||
Aanvraag.Decided.Afgewezen => Besluit.Afwijzen,
|
||||
Aanvraag.Decided.MeerInfoGevraagd => Besluit.MeerInfoOpvragen,
|
||||
_ => throw new InvalidOperationException($"Unknown Decided variant {d.GetType().Name}."),
|
||||
};
|
||||
row.BesluitToelichting = d switch
|
||||
{
|
||||
Aanvraag.Decided.Goedgekeurd => null,
|
||||
Aanvraag.Decided.Afgewezen af => af.Toelichting,
|
||||
Aanvraag.Decided.MeerInfoGevraagd m => m.Toelichting,
|
||||
_ => throw new InvalidOperationException($"Unknown Decided variant {d.GetType().Name}."),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A brand-new row for a domain value that has no existing row yet — test fixtures'
|
||||
/// <c>db.Applications.Add(...)</c> (see <c>Acceptance/BesluitLifecycleTests.cs</c>,
|
||||
/// <c>Acceptance/IntakeSubmissionTests.cs</c>), and (indirectly, via <see cref="ApplyTo"/>)
|
||||
/// <see cref="ApplicationStore.CreateConcept"/>'s very first insert.</summary>
|
||||
public static AanvraagEntity ToEntity(this Aanvraag a)
|
||||
{
|
||||
var row = new AanvraagEntity { Id = a.Id, Type = a.Type, Owner = a.Owner, CreatedAt = a.CreatedAt };
|
||||
a.ApplyTo(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>The status at a point in time (WP-68 F3, WP-73) — pattern matching over the
|
||||
/// closed <see cref="Aanvraag"/> union, replacing the null-forgiving derefs the old flat
|
||||
/// mutable row needed (Referentie/SubmittedAt are simply non-nullable on Submitted/Decided
|
||||
/// now, so there's nothing left to force). A recorded decision wins over the auto-approve
|
||||
/// computation, matching the pre-WP-73 priority.</summary>
|
||||
public static AanvraagStatus StatusAt(this Aanvraag a, DateTimeOffset now) => a switch
|
||||
{
|
||||
Aanvraag.Concept c => AanvraagStatus.Concept(c.StepIndex, c.StepCount),
|
||||
Aanvraag.Submitted { Reden: { } reden } s => AanvraagStatus.Afgewezen(s.Referentie, reden),
|
||||
Aanvraag.Decided.Goedgekeurd g => AanvraagStatus.Goedgekeurd(g.Referentie),
|
||||
Aanvraag.Decided.Afgewezen af => AanvraagStatus.Afgewezen(af.Referentie, af.Toelichting),
|
||||
Aanvraag.Decided.MeerInfoGevraagd m => AanvraagStatus.MeerInfoGevraagd(m.Referentie, m.Toelichting),
|
||||
Aanvraag.Submitted s when s.AutoApprovable && now > s.SubmittedAt + ApplicationStore.ProcessingWindow =>
|
||||
AanvraagStatus.Goedgekeurd(s.Referentie),
|
||||
Aanvraag.Submitted s => AanvraagStatus.InBehandeling(s.Referentie, manual: !s.AutoApprovable),
|
||||
_ => throw new InvalidOperationException($"Unknown Aanvraag variant {a.GetType().Name}."),
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core/SQLite persistence for the three stores that used to be static
|
||||
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
|
||||
/// in-memory dictionaries (WP-22): <see cref="AanvraagEntity"/>, <see cref="StoredDocument"/>
|
||||
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
|
||||
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
|
||||
/// JSON text columns rather than redesigned into relational tables — the backend
|
||||
@@ -21,7 +21,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||
public DbSet<AuthzAuditEntry> AuthzAudit => Set<AuthzAuditEntry>();
|
||||
public DbSet<FeatureFlagEntity> FeatureFlags => Set<FeatureFlagEntity>();
|
||||
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||
public DbSet<AanvraagEntity> Applications => Set<AanvraagEntity>();
|
||||
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
|
||||
modelBuilder.Entity<FeatureFlagEntity>().HasKey(f => f.Key);
|
||||
|
||||
modelBuilder.Entity<Aanvraag>(e =>
|
||||
modelBuilder.Entity<AanvraagEntity>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
e.Property(a => a.Draft).HasConversion(DraftConverter);
|
||||
|
||||
@@ -6,13 +6,16 @@ using BigRegister.Domain.Submissions;
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
|
||||
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
|
||||
/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen
|
||||
/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see <see cref="StatusAt"/>) so
|
||||
/// auto-approval is purely a function of stored timestamps — no timers, no jobs.
|
||||
/// The EF-mapped persistence row for an application (aanvraag) — WP-73 demoted this to
|
||||
/// exactly that: a flat, mutable bag with no invariants of its own (SQLite needs precisely
|
||||
/// this shape), never read or written directly outside this file. Everywhere else, production
|
||||
/// code reads and writes <see cref="Aanvraag"/> (the closed Concept/Submitted/Decided domain
|
||||
/// union, <c>Domain/Applications/Aanvraag.cs</c>) — <see cref="AanvraagMapper"/>'s
|
||||
/// <c>ToDomain</c>/<c>ApplyTo</c>/<c>ToEntity</c> is the two-way seam between the two. Status is
|
||||
/// COMPUTED ON READ (see the <c>StatusAt</c> extension below) so auto-approval is purely a
|
||||
/// function of stored timestamps — no timers, no jobs.
|
||||
/// </summary>
|
||||
public sealed class Aanvraag
|
||||
public sealed class AanvraagEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Type { get; init; } // registratie | herregistratie | intake
|
||||
@@ -53,26 +56,6 @@ public sealed class Aanvraag
|
||||
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
|
||||
/// the published status's Reden), optional for Goedkeuren.</summary>
|
||||
public string? BesluitToelichting { get; set; }
|
||||
|
||||
/// <summary>The status at a point in time (WP-68 F3) — moved here from
|
||||
/// <c>Contracts.Mappers.ToStatusDto</c>, which is now a one-line projection of this. A
|
||||
/// recorded decision wins over the auto-approve computation below.</summary>
|
||||
public AanvraagStatus StatusAt(DateTimeOffset now)
|
||||
{
|
||||
if (!Submitted) return AanvraagStatus.Concept(StepIndex, StepCount);
|
||||
if (Reden is not null) return AanvraagStatus.Afgewezen(Referentie!, Reden);
|
||||
if (BesluitStatus is { } besluit)
|
||||
return besluit switch
|
||||
{
|
||||
Besluit.Goedkeuren => AanvraagStatus.Goedgekeurd(Referentie!),
|
||||
Besluit.Afwijzen => AanvraagStatus.Afgewezen(Referentie!, BesluitToelichting),
|
||||
Besluit.MeerInfoOpvragen => AanvraagStatus.MeerInfoGevraagd(Referentie!, BesluitToelichting),
|
||||
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
|
||||
};
|
||||
if (AutoApprovable && now > SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||
return AanvraagStatus.Goedgekeurd(Referentie!);
|
||||
return AanvraagStatus.InBehandeling(Referentie!, manual: !AutoApprovable);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,10 +73,13 @@ public static class ApplicationStore
|
||||
|
||||
/// Create a Concept for <paramref name="owner"/> — UNLESS one of this
|
||||
/// <paramref name="type"/> already exists unsubmitted. WP-35: at most one Concept per
|
||||
/// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort).
|
||||
/// Race-free: the existence check and the insert share the single write gate. Returns
|
||||
/// null when a duplicate would be created (the caller maps that to 409 Conflict).
|
||||
public static Aanvraag? CreateConcept(string type, string owner)
|
||||
/// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort;
|
||||
/// this stays procedural here — it's an AGGREGATE-SET rule over every (Owner, Type), not
|
||||
/// something a single Aanvraag value's own shape could ever encode, and there is no unique
|
||||
/// index in the schema either). Race-free: the existence check and the insert share the
|
||||
/// single write gate. Returns null when a duplicate would be created (the caller maps that
|
||||
/// to 409 Conflict).
|
||||
public static Aanvraag.Concept? CreateConcept(string type, string owner)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
lock (_gate)
|
||||
@@ -101,10 +87,18 @@ public static class ApplicationStore
|
||||
using var db = Db.Create();
|
||||
if (db.Applications.Any(a => a.Owner == owner && a.Type == type && !a.Submitted))
|
||||
return null;
|
||||
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
||||
db.Applications.Add(a);
|
||||
var concept = new Aanvraag.Concept(stepIndex: 0, stepCount: 0)
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Type = type,
|
||||
Owner = owner,
|
||||
DocumentIds = Array.Empty<string>(),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.Applications.Add(concept.ToEntity());
|
||||
db.SaveChanges();
|
||||
return a;
|
||||
return concept;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +108,7 @@ public static class ApplicationStore
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
return a is not null && a.Owner == owner ? a : null;
|
||||
return a is not null && a.Owner == owner ? a.ToDomain() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +117,7 @@ public static class ApplicationStore
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Applications.Where(a => a.Owner == owner).ToList();
|
||||
return db.Applications.Where(a => a.Owner == owner).ToList().Select(a => a.ToDomain()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +128,7 @@ public static class ApplicationStore
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Applications.Find(id);
|
||||
return db.Applications.Find(id)?.ToDomain();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +143,7 @@ public static class ApplicationStore
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Applications.FirstOrDefault(a => a.Referentie == referentie);
|
||||
return db.Applications.FirstOrDefault(a => a.Referentie == referentie)?.ToDomain();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,23 +156,34 @@ public static class ApplicationStore
|
||||
using var db = Db.Create();
|
||||
// Order client-side: SQLite can't ORDER BY a DateTimeOffset (same constraint the
|
||||
// rest of the store sidesteps by never sorting in the query).
|
||||
return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).ToList();
|
||||
return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).Select(a => a.ToDomain()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable — the
|
||||
/// domain reconstruction below is what enforces "0 <= StepIndex <= StepCount"
|
||||
/// (<see cref="Aanvraag.Concept"/>'s own constructor throws on an out-of-range pair instead
|
||||
/// of this silently writing one onto the row, the way the pre-WP-73 code did).
|
||||
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null || a.Owner != owner || a.Submitted) return false;
|
||||
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
||||
a.StepIndex = stepIndex;
|
||||
a.StepCount = stepCount;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
var row = db.Applications.Find(id);
|
||||
if (row is null || row.Owner != owner || row.Submitted) return false;
|
||||
var concept = new Aanvraag.Concept(stepIndex, stepCount)
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = documentIds ?? row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Draft = draft.Clone(), // detach from the request's JsonDocument (disposed after the call)
|
||||
};
|
||||
concept.ApplyTo(row);
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
@@ -226,23 +231,39 @@ public static class ApplicationStore
|
||||
|
||||
/// 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)
|
||||
/// if the aanvraag is gone or already submitted (idempotency guard). WP-73: the returned
|
||||
/// <see cref="Aanvraag.Submitted"/> is constructed with a non-null Referentie/SubmittedAt by
|
||||
/// its own required members — there is no longer a null-forgiving deref anywhere down the
|
||||
/// line reading them back (<c>StatusAt</c>, <c>IZaakSource.CreateZaak</c>). Submitting also
|
||||
/// clears the row's Draft (<see cref="AanvraagMapper.ApplyTo"/>'s Submitted branch) — nothing
|
||||
/// reads a submitted aanvraag's draft (the FE only ever resumes a still-Concept wizard), so
|
||||
/// the doc-comment's old "Draft is Concept only" claim is now actually true, not aspirational.
|
||||
public static Aanvraag.Submitted? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null || 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();
|
||||
var row = db.Applications.Find(id);
|
||||
if (row is null || row.Owner != owner || row.Submitted) return null;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var submitted = new Aanvraag.Submitted
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = documentIds ?? row.DocumentIds,
|
||||
CreatedAt = row.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = SubmissionRules.NewReference(),
|
||||
SubmittedAt = now,
|
||||
AutoApprovable = autoApprovable,
|
||||
Reden = reject,
|
||||
};
|
||||
submitted.ApplyTo(row);
|
||||
db.SaveChanges();
|
||||
return a;
|
||||
return submitted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,22 +303,83 @@ public static class ApplicationStore
|
||||
/// now runs INSIDE this lock, against a status read fresh under the lock, rather than in
|
||||
/// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's
|
||||
/// check before either wrote, letting the second silently overwrite a terminal decision.
|
||||
/// WP-73: <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
|
||||
/// require a non-null Toelichting by their own shape — the endpoint already 400s a missing
|
||||
/// one (<c>BeoordelingRules.RequiresToelichting</c>), and this is the defense-in-depth
|
||||
/// backstop for any other caller (this method is public, and e.g.
|
||||
/// <c>Acceptance/BesluitLifecycleTests.cs</c> calls it directly, bypassing the endpoint).
|
||||
/// </summary>
|
||||
public static (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
|
||||
public static (RecordBesluitOutcome Outcome, Aanvraag.Decided? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null) return (RecordBesluitOutcome.NotFound, null);
|
||||
var current = a.StatusAt(now).Tag;
|
||||
if (current is null || !BeoordelingRules.CanDecide(current.Value))
|
||||
var row = db.Applications.Find(id);
|
||||
if (row is null) return (RecordBesluitOutcome.NotFound, null);
|
||||
|
||||
var current = row.ToDomain();
|
||||
var tag = current.StatusAt(now).Tag;
|
||||
if (tag is null || !BeoordelingRules.CanDecide(tag.Value))
|
||||
return (RecordBesluitOutcome.Conflict, null);
|
||||
a.BesluitStatus = besluit;
|
||||
a.BesluitToelichting = toelichting;
|
||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// tag non-null ⇒ current is Submitted or already Decided, never Concept ⇒ Referentie/
|
||||
// SubmittedAt already exist — carried forward rather than re-derived.
|
||||
var (referentie, submittedAt) = current switch
|
||||
{
|
||||
Aanvraag.Submitted s => (s.Referentie, s.SubmittedAt),
|
||||
Aanvraag.Decided d => (d.Referentie, d.SubmittedAt),
|
||||
_ => throw new InvalidOperationException($"Aanvraag {id} has a decidable status but is not submitted."),
|
||||
};
|
||||
|
||||
Aanvraag.Decided decided = besluit switch
|
||||
{
|
||||
Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = current.DocumentIds,
|
||||
CreatedAt = current.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
},
|
||||
Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = current.DocumentIds,
|
||||
CreatedAt = current.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
Toelichting = toelichting ?? throw new InvalidOperationException("Afwijzen requires a toelichting."),
|
||||
},
|
||||
Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd
|
||||
{
|
||||
Id = row.Id,
|
||||
Type = row.Type,
|
||||
Owner = row.Owner,
|
||||
DocumentIds = current.DocumentIds,
|
||||
CreatedAt = current.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
ZaakUrl = row.ZaakUrl,
|
||||
ZgwError = row.ZgwError,
|
||||
Referentie = referentie,
|
||||
SubmittedAt = submittedAt,
|
||||
Toelichting = toelichting ?? throw new InvalidOperationException("MeerInfoOpvragen requires a toelichting."),
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown besluit {besluit}."),
|
||||
};
|
||||
|
||||
decided.ApplyTo(row); // also sets row.UpdatedAt = decided.UpdatedAt (= now, above)
|
||||
db.SaveChanges();
|
||||
return (RecordBesluitOutcome.Ok, a);
|
||||
return (RecordBesluitOutcome.Ok, decided);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,20 @@ public interface IZaakSource
|
||||
|
||||
/// <summary>
|
||||
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
|
||||
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
|
||||
/// integration side-effect, and (Referentie, Status) is what the submit endpoint hands back
|
||||
/// to the FE (ADR-0001: route the create through the existing submit response DTO, don't add
|
||||
/// a second one). The local source is a pure passthrough of the already-computed local
|
||||
/// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak
|
||||
/// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL
|
||||
/// so the endpoint can persist it (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
|
||||
/// to later link documents to this zaak). <paramref name="caller"/> (WP-53) is the acting
|
||||
/// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
|
||||
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran, hence the
|
||||
/// <see cref="Aanvraag.Submitted"/> parameter type — WP-73: a freshly submitted aanvraag
|
||||
/// always has a Referentie, so neither implementation needs a null-forgiving deref for it
|
||||
/// any more) — this is the integration side-effect, and (Referentie, Status) is what the
|
||||
/// submit endpoint hands back to the FE (ADR-0001: route the create through the existing
|
||||
/// submit response DTO, don't add a second one). The local source is a pure passthrough of
|
||||
/// the already-computed local reference/status (ZaakUrl null — nothing to persist); the
|
||||
/// OpenZaak source creates a Zaak (+ status + rol) and maps the result back into the same
|
||||
/// shape, returning the zaak's URL so the endpoint can persist it
|
||||
/// (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it to later link documents to this
|
||||
/// zaak). <paramref name="caller"/> (WP-53) is the acting citizen — the ZGW JWT's audit
|
||||
/// claims reflect them, not a static config identity.
|
||||
/// </summary>
|
||||
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
|
||||
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller);
|
||||
|
||||
/// <summary>
|
||||
/// Extend a behandelaar's already-locally-recorded decision (WP-65b's
|
||||
|
||||
@@ -24,8 +24,8 @@ public sealed class LocalZaakSource : IZaakSource
|
||||
|
||||
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record
|
||||
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
|
||||
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
|
||||
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
|
||||
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) =>
|
||||
(aanvraag.Referentie, aanvraag.ToStatusDto(now), null);
|
||||
|
||||
/// <summary>No external zaak to update — the recorded decision already IS the record of
|
||||
/// truth locally (WP-66). Zero behaviour change.</summary>
|
||||
|
||||
@@ -16,7 +16,7 @@ public static class SeedData
|
||||
Beroep: "Arts",
|
||||
Registratiedatum: new DateOnly(2012, 9, 1),
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||
Status: new RegistrationStatus.Geregistreerd(HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||
|
||||
public static readonly Person Person = new(
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BigRegister.Domain.Applications;
|
||||
|
||||
/// <summary>
|
||||
/// The aanvraag lifecycle as a closed union (WP-73): <see cref="Concept"/> (the pre-submission
|
||||
/// wizard draft) → <see cref="Submitted"/> (awaiting a behandelaar's decision, or already
|
||||
/// auto-rejected at submission time — see <see cref="Submitted.Reden"/>) → <see cref="Decided"/>
|
||||
/// (a behandelaar's outcome recorded). Each variant carries only the fields that make sense for
|
||||
/// it; the private base constructor closes the hierarchy to the nested sealed records below, so
|
||||
/// a caller can never construct a fourth variant, a <see cref="Decided"/> with no referentie, or
|
||||
/// an Afwijzen/MeerInfoGevraagd with no toelichting — each is a compile error (a missing
|
||||
/// `required` member, CS9035), not a runtime null-check the way the old flat, mutable
|
||||
/// <c>Aanvraag</c> needed one.
|
||||
///
|
||||
/// <see cref="Api.Data.AanvraagEntity"/> is the EF-mapped persistence row this maps to/from
|
||||
/// (<c>Api.Data.AanvraagMapper</c>'s <c>ToDomain</c>/<c>ApplyTo</c>/<c>ToEntity</c>) — it stays a
|
||||
/// flat, mutable bag with no invariants of its own (SQLite needs exactly that shape); this type
|
||||
/// is what production code actually reads and writes everywhere else. The wire-facing,
|
||||
/// point-in-time <see cref="AanvraagStatus"/> a screen renders is a further, time-dependent
|
||||
/// projection (<c>StatusAt</c>, in <c>Api.Data</c>) — the auto-approval window is a function of
|
||||
/// wall-clock time, not of this stored shape, so it stays a derived read rather than a fourth
|
||||
/// member of this union.
|
||||
/// </summary>
|
||||
public abstract record Aanvraag
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Type { get; init; } // registratie | herregistratie | intake
|
||||
public required string Owner { get; init; }
|
||||
public required IReadOnlyList<string> DocumentIds { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under
|
||||
/// the local source, or before a zaak has been registered at all.</summary>
|
||||
public string? ZaakUrl { get; init; }
|
||||
|
||||
/// <summary>WP-60: non-null means the ZGW side of this aanvraag's last write did not
|
||||
/// complete — see <c>Api.Data.ApplicationStore.SetZgwError</c>.</summary>
|
||||
public string? ZgwError { get; init; }
|
||||
|
||||
private Aanvraag() { }
|
||||
|
||||
/// <summary>Pre-submission wizard draft.</summary>
|
||||
public sealed record Concept : Aanvraag
|
||||
{
|
||||
public JsonElement? Draft { get; init; }
|
||||
public int StepIndex { get; }
|
||||
public int StepCount { get; }
|
||||
|
||||
/// <summary>0 <= <paramref name="stepIndex"/> <= <paramref name="stepCount"/> — the
|
||||
/// non-strict upper bound, not the strict "<" a wizard's own step cursor uses, because
|
||||
/// <c>ApplicationStore.CreateConcept</c>'s freshly-created row is (StepIndex: 0, StepCount:
|
||||
/// 0) before the wizard's first draft sync ever runs, and that has to stay constructible.
|
||||
/// </summary>
|
||||
public Concept(int stepIndex, int stepCount)
|
||||
{
|
||||
if (stepIndex < 0 || stepCount < 0 || stepIndex > stepCount)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(stepIndex), stepIndex, $"StepIndex must be within [0, StepCount ({stepCount})].");
|
||||
StepIndex = stepIndex;
|
||||
StepCount = stepCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Submitted, no behandelaar decision recorded yet. <see cref="Reden"/> non-null
|
||||
/// means <c>SubmissionRules</c> rejected it automatically at submission time (e.g. a manually
|
||||
/// entered diploma) — terminal in practice (<c>BeoordelingRules.CanDecide</c> refuses a
|
||||
/// besluit once the projected status is already Afgewezen) but structurally still "no besluit
|
||||
/// was ever recorded", hence it lives here rather than in <see cref="Decided"/>.</summary>
|
||||
public sealed record Submitted : Aanvraag
|
||||
{
|
||||
public required string Referentie { get; init; }
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
public required bool AutoApprovable { get; init; }
|
||||
public string? Reden { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A behandelaar's decision (WP-65b/68) — closed by besluit: only
|
||||
/// <see cref="Afgewezen"/>/<see cref="MeerInfoGevraagd"/> require a toelichting
|
||||
/// (<c>BeoordelingRules.RequiresToelichting</c>'s rule, now also a type, not just an endpoint
|
||||
/// check) — omitting it is a compile error, not merely a 400 the type happens to also let
|
||||
/// slip through at runtime.</summary>
|
||||
public abstract record Decided : Aanvraag
|
||||
{
|
||||
public required string Referentie { get; init; }
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
|
||||
private Decided() { }
|
||||
|
||||
public sealed record Goedgekeurd : Decided;
|
||||
|
||||
public sealed record Afgewezen : Decided
|
||||
{
|
||||
public required string Toelichting { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MeerInfoGevraagd : Decided
|
||||
{
|
||||
public required string Toelichting { get; init; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ namespace BigRegister.Domain.Intake;
|
||||
/// scholing question is required. The frontend receives this value
|
||||
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
|
||||
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the
|
||||
/// backend re-validating it as the authority on submit (WP-69) — both
|
||||
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) and the legacy
|
||||
/// <c>POST /intakes</c> call it before writing anything, and a violation 400s
|
||||
/// (<c>ProblemDetails</c>), never silently accepts an incomplete answer.
|
||||
/// backend re-validating it as the authority on submit (WP-69) —
|
||||
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) calls it before
|
||||
/// writing anything, and a violation 400s (<c>ProblemDetails</c>), never silently accepts
|
||||
/// an incomplete answer.
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ public static class HerregistratieRule
|
||||
public const int WindowMonths = 12;
|
||||
|
||||
public static DateOnly? Deadline(Registration reg) =>
|
||||
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
||||
reg.Status is RegistrationStatus.Geregistreerd g ? g.HerregistratieDatum : null;
|
||||
|
||||
public static (bool Eligible, string? Reason) Evaluate(
|
||||
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
||||
@@ -25,8 +25,4 @@ public static class HerregistratieRule
|
||||
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
||||
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
||||
}
|
||||
|
||||
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
||||
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
||||
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
|
||||
}
|
||||
|
||||
@@ -9,15 +9,37 @@ public enum StatusTag
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status as a flat record: only <see cref="StatusTag.Geregistreerd"/> carries a
|
||||
/// herregistratie deadline. The frontend mirrors this as a discriminated union.
|
||||
/// Status as a closed union: each variant carries exactly the data that makes sense for it
|
||||
/// (WP-73). Only <see cref="Geregistreerd"/> carries a herregistratie deadline; only
|
||||
/// <see cref="Geschorst"/> and <see cref="Doorgehaald"/> carry a reden — and there it is
|
||||
/// required, not nullable (the old flat record left <c>Reden</c> nullable on every tag,
|
||||
/// diverging from the frontend union, which has always required it on those two variants —
|
||||
/// see <c>registratie/domain/registration.ts</c>). The private base constructor closes the
|
||||
/// hierarchy: only the three nested sealed records below can ever inherit from
|
||||
/// <see cref="RegistrationStatus"/>, so a caller can never construct e.g. a
|
||||
/// <see cref="Geschorst"/> with a herregistratie date, or a fourth variant.
|
||||
/// </summary>
|
||||
public sealed record RegistrationStatus(
|
||||
StatusTag Tag,
|
||||
DateOnly? HerregistratieDatum = null,
|
||||
DateOnly? GeschorstTot = null,
|
||||
string? Reden = null,
|
||||
DateOnly? DoorgehaaldOp = null);
|
||||
public abstract record RegistrationStatus
|
||||
{
|
||||
public abstract StatusTag Tag { get; }
|
||||
|
||||
private RegistrationStatus() { }
|
||||
|
||||
public sealed record Geregistreerd(DateOnly HerregistratieDatum) : RegistrationStatus
|
||||
{
|
||||
public override StatusTag Tag => StatusTag.Geregistreerd;
|
||||
}
|
||||
|
||||
public sealed record Geschorst(DateOnly GeschorstTot, string Reden) : RegistrationStatus
|
||||
{
|
||||
public override StatusTag Tag => StatusTag.Geschorst;
|
||||
}
|
||||
|
||||
public sealed record Doorgehaald(DateOnly DoorgehaaldOp, string Reden) : RegistrationStatus
|
||||
{
|
||||
public override StatusTag Tag => StatusTag.Doorgehaald;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record Registration(
|
||||
string BigNummer,
|
||||
|
||||
@@ -189,24 +189,6 @@ api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents))
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
|
||||
{
|
||||
// WP-69: completeness check outside Submit(...) — deliberately not folded into `reject`,
|
||||
// so this 400 is never cached in IdempotencyStore the way a 422 rejection would be.
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
if (reject is null && IntakePolicy.RejectIncompleteScholing(req.Uren, req.AanvullendeScholing, req.ScholingPunten) is { } incomplete)
|
||||
return Results.Problem(detail: incomplete, statusCode: StatusCodes.Status400BadRequest);
|
||||
return Submit(ctx, "intake", reject);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon)))
|
||||
.Produces<ReferentieResponse>()
|
||||
@@ -344,7 +326,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
|
||||
{
|
||||
var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
|
||||
if (a is null) return Results.NotFound();
|
||||
if (a.Submitted)
|
||||
if (a is not Aanvraag.Concept)
|
||||
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
|
||||
ApplicationStore.Delete(id, ctx.Zorgverlener().Bsn);
|
||||
return Results.NoContent();
|
||||
@@ -359,7 +341,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
{
|
||||
var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
|
||||
if (existing is null) return Results.NotFound();
|
||||
if (existing.Submitted)
|
||||
if (existing is not Aanvraag.Concept)
|
||||
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
|
||||
@@ -404,7 +386,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
// failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged
|
||||
// one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught
|
||||
// separately so a create-zaak failure doesn't also skip the (still-local) document link.
|
||||
var referentie = submitted.Referentie!;
|
||||
var referentie = submitted.Referentie;
|
||||
var status = submitted.ToStatusDto(DateTimeOffset.UtcNow);
|
||||
string? zaakUrl = null;
|
||||
try
|
||||
@@ -525,7 +507,8 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex);
|
||||
// WP-73: Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed.
|
||||
RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex);
|
||||
}
|
||||
|
||||
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
|
||||
|
||||
@@ -94,10 +94,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
/// succeeded. The caller (Program.cs's submit endpoint) catches this and records it as a
|
||||
/// flagged divergence (Aanvraag.ZgwError) instead of letting it fail (or diverge) silently —
|
||||
/// see openzaak-integration.md's "Write resilience" section.
|
||||
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
|
||||
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) =>
|
||||
CreateZaakAsync(aanvraag, now, caller).GetAwaiter().GetResult();
|
||||
|
||||
private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller)
|
||||
private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller)
|
||||
{
|
||||
if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
|
||||
throw new InvalidOperationException(
|
||||
@@ -108,8 +108,9 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
Bronorganisatie: options.Bronorganisatie,
|
||||
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
|
||||
Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
|
||||
Identificatie: aanvraag.Referentie
|
||||
?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")), caller);
|
||||
// WP-73: Aanvraag.Submitted's Referentie is a required, non-nullable member — a
|
||||
// just-submitted aanvraag always has one, so there is nothing left to null-check here.
|
||||
Identificatie: aanvraag.Referentie), caller);
|
||||
|
||||
var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
|
||||
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen",
|
||||
|
||||
Reference in New Issue
Block a user