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:
eho
2026-08-19 16:31:38 +02:00
co-authored by Claude Sonnet 5
parent edaf1360c5
commit 6bc00a917c
26 changed files with 667 additions and 532 deletions
-2
View File
@@ -58,8 +58,6 @@ cd backend && dotnet test # rule unit tests + endpoint integration tests
| GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback |
| GET | `/api/intake/policy` | scholing threshold (config value) |
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) / 400 (incomplete scholing answer) |
Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request
carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it
@@ -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 &lt;= StepIndex &lt;= 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);
}
}
}
+13 -10
View File
@@ -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>
+1 -1
View File
@@ -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 &lt;= <paramref name="stepIndex"/> &lt;= <paramref name="stepCount"/> — the
/// non-strict upper bound, not the strict "&lt;" 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,
+5 -22
View File
@@ -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",
-124
View File
@@ -249,94 +249,6 @@
}
}
},
"/api/v1/herregistraties": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HerregistratieRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/intakes": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IntakeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/change-requests": {
"post": {
"tags": [
@@ -2149,23 +2061,6 @@
},
"additionalProperties": false
},
"HerregistratieRequest": {
"type": "object",
"properties": {
"uren": {
"type": "integer",
"format": "int32"
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"IntakePolicyDto": {
"type": "object",
"properties": {
@@ -2176,25 +2071,6 @@
},
"additionalProperties": false
},
"IntakeRequest": {
"type": "object",
"properties": {
"uren": {
"type": "integer",
"format": "int32"
},
"aanvullendeScholing": {
"type": "boolean",
"nullable": true
},
"scholingPunten": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
},
"LetterBlockDto": {
"type": "object",
"properties": {
@@ -25,7 +25,7 @@ public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFi
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.Applications.Add(aanvraag.ToEntity());
db.SaveChanges();
}
@@ -74,7 +74,7 @@ public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFi
// When the status is read long after the auto-approve window has passed — the instant an
// undecided auto-approvable case of the same shape WOULD read Goedgekeurd (see
// ApplicationTests.AutoApprovable_flips_to_goedgekeurd_after_the_window)...
var longAfterTheWindow = aanvraag.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var longAfterTheWindow = aanvraag.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(longAfterTheWindow);
// Then the recorded decision still wins — Afgewezen, never Goedgekeurd.
@@ -8,13 +8,13 @@ using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance;
/// <summary>
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over both live HTTP
/// paths — <c>POST /applications/{id}/submit</c> (the wizard's real path) and the legacy
/// <c>POST /intakes</c> (dead from the UI, still a live crafted-POST surface). Built through
/// the <see cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/>
/// rather than the full wizard/upload dance — the builder's default owner IS
/// <see cref="BigRegister.Api.Domain.Authorization.StubIdentityProvider"/>'s default caller,
/// so no header juggling.
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over
/// <c>POST /applications/{id}/submit</c> (the wizard's real path — WP-72 deleted the legacy
/// <c>POST /intakes</c> endpoint this once also covered). Built through the <see
/// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather
/// than the full wizard/upload dance — the builder's default owner IS <see
/// cref="BigRegister.Api.Domain.Authorization.StubIdentityProvider"/>'s default caller, so
/// no header juggling.
/// </summary>
public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
@@ -23,7 +23,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.Applications.Add(aanvraag.ToEntity());
db.SaveChanges();
}
@@ -44,8 +44,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
// ...and the aanvraag is left a retryable Concept, never marked Submitted.
var stillConcept = ApplicationStore.GetAny(aanvraag.Id)!;
Assert.False(stillConcept.Submitted);
Assert.IsType<Aanvraag.Concept>(ApplicationStore.GetAny(aanvraag.Id));
}
[Fact]
@@ -121,16 +120,4 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
[Fact]
public async Task Legacy_intakes_endpoint_enforces_it_too()
{
// Given no aanvraag needed — the legacy endpoint mints its own reference.
// When a crafted POST hits the dead-from-the-UI /intakes endpoint below threshold,
// with no scholing answer...
var res = await _client.PostAsJsonAsync("/api/v1/intakes", new { uren = 500 });
// Then it is rejected too — the crafted-POST surface this WP closes.
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
}
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Tests.Builders;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
@@ -227,25 +228,14 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new()
{
Id = "x",
Type = "registratie",
Owner = "test",
Submitted = true,
AutoApprovable = autoApprovable,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
private static Aanvraag.Submitted Accepted(bool autoApprovable) =>
Given.Concept(type: "registratie", owner: "test").Submitted(autoApprovable).Build();
[Fact]
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
{
var a = Accepted(autoApprovable: true);
Assert.NotNull(a.SubmittedAt);
var t0 = a.SubmittedAt.Value;
var t0 = a.SubmittedAt;
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
}
@@ -254,8 +244,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public void Manual_case_never_auto_advances()
{
var a = Accepted(autoApprovable: false);
Assert.NotNull(a.SubmittedAt);
var far = a.SubmittedAt.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var far = a.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = a.ToStatusDto(far);
Assert.Equal("InBehandeling", status.Tag);
Assert.True(status.Manual);
@@ -26,7 +26,7 @@ file sealed class IdMismatchZaakSource : IZaakSource
inner.ListMyCases(caller, now).Select(Rekey).ToList();
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(
Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller);
Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller);
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller);
@@ -1,7 +1,6 @@
using System.Threading;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
namespace BigRegister.Tests.Builders;
@@ -15,18 +14,16 @@ public static class TestIdentities
}
/// <summary>
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70). "Build test data through the
/// same door production code uses" — a Concept can only ever become Submitted, and only a
/// Submitted aanvraag can be Decided, so the compiler refuses a fixture built through an illegal
/// path (e.g. deciding a still-Concept aanvraag) instead of that being a runtime assertion nobody
/// wrote. Start at <see cref="Given.Concept"/>.
///
/// ponytail: <see cref="Aanvraag"/> itself stays exactly what it always was — a mutable,
/// EF-backed bag with no invariants of its own (that's Data/ApplicationStore.cs's job in
/// production, via its own lock + <see cref="BeoordelingRules"/> checks). This builder does not
/// refactor it into an immutable aggregate; it's the one enforced DOOR through which TEST code
/// builds one, so the invariants a real request path enforces don't quietly go missing from a
/// fixture assembled by hand.
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70; simplified at WP-73). "Build
/// test data through the same door production code uses" — <see cref="Aanvraag"/> itself is now
/// the closed Concept/Submitted/Decided union WP-73 introduced, so this builder no longer needs
/// to mirror production's guards (step-index bounds, "Afwijzen needs a toelichting") by hand —
/// it just calls the real nested constructors/required members, which enforce them. A call that
/// would build an illegal Aanvraag (e.g. deciding a still-Concept aanvraag, or an Afwijzen with
/// no toelichting) is refused the same way production refuses it: a still-Concept aanvraag has
/// no <c>.Decided(...)</c> to call in the first place, and a missing toelichting is a runtime
/// guard identical to <c>ApplicationStore.RecordBesluit</c>'s own. Start at
/// <see cref="Given.Concept"/>.
/// </summary>
public static class Given
{
@@ -52,60 +49,50 @@ public sealed class ConceptAanvraag
}
/// The wizard's current position — step <paramref name="index"/> of <paramref name="of"/>.
/// Guarded the same way a real cursor is (`STEPS[Math.min(cursor, STEPS.length - 1)]` on the
/// frontend): <paramref name="of"/> must be at least 1, and <paramref name="index"/> must fall
/// within <c>[0, of)</c> — <c>AtStep(9, 2)</c> is not a position any real wizard can reach, so
/// the builder refuses it instead of silently building an impossible fixture.
/// Bounds are <see cref="Aanvraag.Concept"/>'s OWN constructor's to enforce, not this
/// builder's — an out-of-range pair fails at <see cref="Build"/>, the same
/// <see cref="ArgumentOutOfRangeException"/> production throws, not a guard restated here.
public ConceptAanvraag AtStep(int index, int of)
{
if (of < 1)
throw new ArgumentOutOfRangeException(nameof(of), of, "Step count must be at least 1.");
if (index < 0 || index >= of)
throw new ArgumentOutOfRangeException(nameof(index), index, $"Step index must be within [0, {of}).");
_stepIndex = index;
_stepCount = of;
return this;
}
/// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors
/// <c>ApplicationStore.Submit</c>), so <c>Aanvraag.StatusAt</c>'s <c>Referentie!</c> is honest
/// for every fixture built this way, never a null-ref waiting to happen.
public SubmittedAanvraag Submitted(bool autoApprovable = false) =>
new(_type, _owner, _stepIndex, _stepCount, autoApprovable);
/// <c>ApplicationStore.Submit</c>), so a fixture built this way can never hit the
/// null-forgiving derefs the pre-WP-73 flat Aanvraag needed (there's nothing to force any
/// more: both are required, non-null members of <see cref="Aanvraag.Submitted"/>).
public SubmittedAanvraag Submitted(bool autoApprovable = false) => new(_type, _owner, autoApprovable);
public Aanvraag Build() => new()
public Aanvraag.Concept Build() => new(_stepIndex, _stepCount)
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
DocumentIds = Array.Empty<string>(),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>A submitted aanvraag, open for a behandelaar's decision. The only next step is
/// <see cref="Decided"/> — there is no way back to <c>ConceptAanvraag</c>.</summary>
/// <see cref="Decided"/> — there is no way back to <see cref="ConceptAanvraag"/>.</summary>
public sealed class SubmittedAanvraag
{
private static int _referentieSeq;
private readonly string _type;
private readonly string _owner;
private readonly int _stepIndex;
private readonly int _stepCount;
private readonly bool _autoApprovable;
private readonly string _referentie;
private readonly DateTimeOffset _submittedAt;
private string? _zaakUrl;
internal SubmittedAanvraag(string type, string owner, int stepIndex, int stepCount, bool autoApprovable)
internal SubmittedAanvraag(string type, string owner, bool autoApprovable)
{
_type = type;
_owner = owner;
_stepIndex = stepIndex;
_stepCount = stepCount;
_autoApprovable = autoApprovable;
// A plausible reference in SubmissionRules.NewReference's shape ("BIG-2026-" + a number) —
// sequential (not random) so a fixture's value is reproducible across a test run.
@@ -114,67 +101,97 @@ public sealed class SubmittedAanvraag
}
/// <summary>Registers this aanvraag's already-known OpenZaak zaak URL — mirrors
/// <see cref="Api.Data.ApplicationStore.SetZaakUrl"/>, the one production writer of this
/// field, so a fixture that needs a pre-existing zaak doesn't reach past <c>Build()</c> to
/// mutate the result by hand.</summary>
/// <see cref="ApplicationStore.SetZaakUrl"/>, the one production writer of this field, so a
/// fixture that needs a pre-existing zaak doesn't reach past <c>Build()</c> to mutate the
/// result by hand.</summary>
public SubmittedAanvraag WithZaakUrl(string zaakUrl)
{
_zaakUrl = zaakUrl;
return this;
}
/// <summary>Records a behandelaar's decision — reusing <see cref="BeoordelingRules.RequiresToelichting"/>,
/// the SAME rule production's besluit endpoint runs, rather than restating it here where it
/// could quietly drift. Throws <see cref="ArgumentException"/> for an Afwijzen/MeerInfoOpvragen
/// with a null/blank <paramref name="toelichting"/> — exactly what that endpoint rejects with
/// a 400, just caught here at fixture-build time instead.</summary>
public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null)
/// <summary>Records a behandelaar's decision. Unlike the pre-WP-73 builder, there is no
/// hand-written toelichting guard mirroring <c>BeoordelingRules.RequiresToelichting</c> any
/// more — <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// simply have a `required string Toelichting` member; the null-coalescing throw below is the
/// one place a null has to turn into an exception (this method's own parameter is still the
/// nullable <c>string?</c> a wire request would carry), same failure production's own
/// <c>ApplicationStore.RecordBesluit</c> raises for the identical input.</summary>
public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null) => new(BuildDecided(besluit, toelichting));
private Aanvraag.Decided BuildDecided(Besluit besluit, string? toelichting)
{
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(toelichting))
throw new ArgumentException($"{besluit} requires a toelichting.", nameof(toelichting));
return new DecidedAanvraag(this, besluit, toelichting);
var (id, createdAt) = (Guid.NewGuid().ToString(), _submittedAt);
return besluit switch
{
Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd
{
Id = id,
Type = _type,
Owner = _owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = createdAt,
UpdatedAt = createdAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
},
Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen
{
Id = id,
Type = _type,
Owner = _owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = createdAt,
UpdatedAt = createdAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
Toelichting = toelichting ?? throw new ArgumentException("Afwijzen requires a toelichting.", nameof(toelichting)),
},
Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd
{
Id = id,
Type = _type,
Owner = _owner,
DocumentIds = Array.Empty<string>(),
CreatedAt = createdAt,
UpdatedAt = createdAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
Toelichting = toelichting ?? throw new ArgumentException("MeerInfoOpvragen requires a toelichting.", nameof(toelichting)),
},
_ => throw new ArgumentOutOfRangeException(nameof(besluit), besluit, "Unknown besluit."),
};
}
public Aanvraag Build() => new()
public Aanvraag.Submitted Build() => new()
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
Submitted = true,
Referentie = _referentie,
AutoApprovable = _autoApprovable,
SubmittedAt = _submittedAt,
DocumentIds = Array.Empty<string>(),
CreatedAt = _submittedAt,
UpdatedAt = _submittedAt,
ZaakUrl = _zaakUrl,
Referentie = _referentie,
SubmittedAt = _submittedAt,
AutoApprovable = _autoApprovable,
};
}
/// <summary>A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the
/// builder too — there's nothing past <see cref="Build"/>, matching Goedgekeurd/Afgewezen being
/// terminal in the domain (<see cref="BeoordelingRules.CanDecide"/>); a fixture that needs a
/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would.</summary>
public sealed class DecidedAanvraag
/// <summary>A submitted aanvraag with a behandelaar's decision already recorded — terminal in
/// the builder too, matching Goedgekeurd/Afgewezen being terminal in the domain
/// (<see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>); a fixture that
/// needs a SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would. Just a one-line
/// wrapper around the already-fully-built <see cref="Aanvraag.Decided"/> value — WP-73 moved
/// all the actual construction (and its invariant enforcement) into
/// <see cref="SubmittedAanvraag.Decided"/> itself, so there's nothing left for this type to do
/// except keep <c>.Decided(...).Build()</c> a valid two-call chain for the existing test
/// suite.</summary>
public sealed class DecidedAanvraag(Aanvraag.Decided value)
{
private readonly SubmittedAanvraag _submitted;
private readonly Besluit _besluit;
private readonly string? _toelichting;
internal DecidedAanvraag(SubmittedAanvraag submitted, Besluit besluit, string? toelichting)
{
_submitted = submitted;
_besluit = besluit;
_toelichting = toelichting;
}
public Aanvraag Build()
{
var aanvraag = _submitted.Build();
aanvraag.BesluitStatus = _besluit;
aanvraag.BesluitToelichting = _toelichting;
return aanvraag;
}
public Aanvraag.Decided Build() => value;
}
@@ -1,3 +1,4 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
using BigRegister.Tests.Builders;
@@ -7,7 +7,7 @@ public class HerregistratieRuleTests
private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
new RegistrationStatus.Geregistreerd(HerregistratieDatum: deadline));
[Fact]
public void Eligible_within_window()
@@ -40,18 +40,9 @@ public class HerregistratieRuleTests
{
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.Geschorst(GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
};
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
Assert.False(eligible);
}
[Fact]
public void Status_consistency_invariant()
{
Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd)));
}
}
@@ -89,27 +89,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
Assert.Contains("application/problem+json", contentType.ToString());
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
// WP-69: 40 is below IntakePolicy.ScholingThreshold, so /intakes now requires the
// scholing question answered — `aanvullendeScholing` is unknown to (and ignored by)
// HerregistratieRequest, so this one extra field keeps serving both rows unchanged.
var res = await _client.PostAsJsonAsync(route, new { uren = 40, aanvullendeScholing = false });
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Change_request_with_valid_phone_succeeds()
{
@@ -156,7 +156,7 @@ public class OpenZaakZaakSourceTests
var zaakBody = handler.BodyOf($"{ZrcBase}/zaken");
Assert.Contains(zaaktypeUrl, zaakBody);
Assert.Contains("123443210", zaakBody);
Assert.Contains(aanvraag.Referentie!, zaakBody);
Assert.Contains(aanvraag.Referentie, zaakBody);
// Status: points at the created zaak's URL and the resolved statustype.
var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
@@ -310,7 +310,7 @@ public class OpenZaakZaakSourceTests
// --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path ---
private static (ZgwOptions options, Aanvraag aanvraag, CallerIdentity caller) CreateZaakFixture()
private static (ZgwOptions options, Aanvraag.Submitted aanvraag, CallerIdentity caller) CreateZaakFixture()
{
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
var options = new ZgwOptions
@@ -405,104 +405,6 @@ export class ApiClient {
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
herregistraties(body: HerregistratieRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/herregistraties";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processHerregistraties(_response);
});
}
protected processHerregistraties(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
intakes(body: IntakeRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/intakes";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processIntakes(_response);
});
}
protected processIntakes(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
@@ -2205,21 +2107,10 @@ export interface HerregistratieDecisionsDto {
herregistratieReason?: string | undefined;
}
export interface HerregistratieRequest {
uren?: number;
documents?: DocumentRefDto[] | undefined;
}
export interface IntakePolicyDto {
scholingThreshold?: number;
}
export interface IntakeRequest {
uren?: number;
aanvullendeScholing?: boolean | undefined;
scholingPunten?: number | undefined;
}
export interface LetterBlockDto {
type?: string | undefined;
blockId?: string | undefined;