From 6bc00a917cc41cb2b681d9362a08191c7401c5a6 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 19 Aug 2026 16:31:38 +0200 Subject: [PATCH] refactor(backend): delete dead legacy endpoints, make domain types unions (WP-72 + WP-73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/README.md | 2 - backend/src/BigRegister.Api/Contracts/Dtos.cs | 10 +- .../src/BigRegister.Api/Contracts/Mappers.cs | 38 ++- .../BigRegister.Api/Data/AanvraagMapper.cs | 206 +++++++++++++++++ .../src/BigRegister.Api/Data/AppDbContext.cs | 6 +- .../BigRegister.Api/Data/ApplicationStore.cs | 216 ++++++++++++------ .../src/BigRegister.Api/Data/IZaakSource.cs | 23 +- .../BigRegister.Api/Data/LocalZaakSource.cs | 4 +- backend/src/BigRegister.Api/Data/SeedData.cs | 2 +- .../Domain/Applications/Aanvraag.cs | 103 +++++++++ .../Domain/Intake/IntakePolicy.cs | 8 +- .../Registrations/HerregistratieRule.cs | 6 +- .../Domain/Registrations/Registration.cs | 38 ++- backend/src/BigRegister.Api/Program.cs | 27 +-- .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 9 +- backend/swagger.json | 124 ---------- .../Acceptance/BesluitLifecycleTests.cs | 4 +- .../Acceptance/IntakeSubmissionTests.cs | 31 +-- .../BigRegister.Tests/ApplicationTests.cs | 21 +- .../BeoordelingIdMismatchTests.cs | 2 +- .../Builders/AanvraagBuilder.cs | 171 +++++++------- .../Domain/BeoordelingRuleTests.cs | 1 + .../Domain/RegistrationRuleTests.cs | 13 +- .../tests/BigRegister.Tests/EndpointTests.cs | 21 -- .../OpenZaakZaakSourceTests.cs | 4 +- libs/shared/src/infrastructure/api-client.ts | 109 --------- 26 files changed, 667 insertions(+), 532 deletions(-) create mode 100644 backend/src/BigRegister.Api/Data/AanvraagMapper.cs create mode 100644 backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs diff --git a/backend/README.md b/backend/README.md index 9245c5c..b55f692 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 44c8bfc..1a8c787 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -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? 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? 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? 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? Documents = null, diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index 8ba9493..d4af6fb 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -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(); + /// SubmittedAt only exists once Submitted/Decided (WP-73) — null for a Concept, + /// same as the wire DTO's own nullable field. + 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, + }; + + /// Draft only exists pre-submission (WP-73) — null once Submitted/Decided (nothing + /// reads it past that point; see AanvraagMapper.ApplyTo's Submitted branch). + 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)); } diff --git a/backend/src/BigRegister.Api/Data/AanvraagMapper.cs b/backend/src/BigRegister.Api/Data/AanvraagMapper.cs new file mode 100644 index 0000000..104d195 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/AanvraagMapper.cs @@ -0,0 +1,206 @@ +using BigRegister.Domain.Applications; + +namespace BigRegister.Api.Data; + +/// +/// The two-way seam between (the EF-mapped persistence row — +/// mutable, no invariants of its own, exactly the shape SQLite needs) and +/// (the closed Concept/Submitted/Decided domain union, WP-73). 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. / +/// are the write half, used by 's writers (and test fixtures, e.g. +/// Builders/AanvraagBuilder.cs) to flush a freshly-constructed domain value onto a row +/// before SaveChanges. +/// +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, + }; + } + + /// 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 + /// 's SyncDraft/Submit/RecordBesluit, each of which already + /// Find()ed the row this applies to. + 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; + } + } + + /// A brand-new row for a domain value that has no existing row yet — test fixtures' + /// db.Applications.Add(...) (see Acceptance/BesluitLifecycleTests.cs, + /// Acceptance/IntakeSubmissionTests.cs), and (indirectly, via ) + /// 's very first insert. + 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; + } + + /// The status at a point in time (WP-68 F3, WP-73) — pattern matching over the + /// closed 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. + 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}."), + }; +} diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs index fa562ca..876789a 100644 --- a/backend/src/BigRegister.Api/Data/AppDbContext.cs +++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs @@ -7,7 +7,7 @@ namespace BigRegister.Api.Data; /// /// EF Core/SQLite persistence for the three stores that used to be static -/// in-memory dictionaries (WP-22): , +/// in-memory dictionaries (WP-22): , /// + , and . 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 options) : DbCon public DbSet AuditEntries => Set(); public DbSet AuthzAudit => Set(); public DbSet FeatureFlags => Set(); - public DbSet Applications => Set(); + public DbSet Applications => Set(); public DbSet Briefs => Set(); public DbSet OrgTemplates => Set(); @@ -43,7 +43,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon modelBuilder.Entity().HasKey(f => f.Key); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(a => a.Id); e.Property(a => a.Draft).HasConversion(DraftConverter); diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index 904f9bd..6160148 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -6,13 +6,16 @@ using BigRegister.Domain.Submissions; namespace BigRegister.Api.Data; /// -/// 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 ) 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 (the closed Concept/Submitted/Decided domain +/// union, Domain/Applications/Aanvraag.cs) — 's +/// ToDomain/ApplyTo/ToEntity is the two-way seam between the two. Status is +/// COMPUTED ON READ (see the StatusAt extension below) so auto-approval is purely a +/// function of stored timestamps — no timers, no jobs. /// -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 /// The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes /// the published status's Reden), optional for Goedkeuren. public string? BesluitToelichting { get; set; } - - /// The status at a point in time (WP-68 F3) — moved here from - /// Contracts.Mappers.ToStatusDto, which is now a one-line projection of this. A - /// recorded decision wins over the auto-approve computation below. - 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); - } } /// @@ -90,10 +73,13 @@ public static class ApplicationStore /// Create a Concept for — UNLESS one of this /// 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(), + 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" + /// ('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? 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? documentIds) + /// if the aanvraag is gone or already submitted (idempotency guard). WP-73: the returned + /// 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 (StatusAt, IZaakSource.CreateZaak). Submitting also + /// clears the row's Draft ('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? 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: / + /// require a non-null Toelichting by their own shape — the endpoint already 400s a missing + /// one (BeoordelingRules.RequiresToelichting), and this is the defense-in-depth + /// backstop for any other caller (this method is public, and e.g. + /// Acceptance/BesluitLifecycleTests.cs calls it directly, bypassing the endpoint). /// - 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); } } } diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index f6fb895..22da698 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -33,17 +33,20 @@ public interface IZaakSource /// /// Register a just-submitted as a zaak (WP-50). The aanvraag is - /// already persisted locally (ApplicationStore.Submit 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 (, WP-51 needs it - /// to later link documents to this zaak). (WP-53) is the acting - /// citizen — the ZGW JWT's audit claims reflect them, not a static config identity. + /// already persisted locally (ApplicationStore.Submit already ran, hence the + /// 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 + /// (, WP-51 needs it to later link documents to this + /// zaak). (WP-53) is the acting citizen — the ZGW JWT's audit + /// claims reflect them, not a static config identity. /// - (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); /// /// Extend a behandelaar's already-locally-recorded decision (WP-65b's diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index 5858cb1..bce8453 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -24,8 +24,8 @@ public sealed class LocalZaakSource : IZaakSource /// 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. - 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); /// No external zaak to update — the recorded decision already IS the record of /// truth locally (WP-66). Zero behaviour change. diff --git a/backend/src/BigRegister.Api/Data/SeedData.cs b/backend/src/BigRegister.Api/Data/SeedData.cs index e2b8b9b..0924e07 100644 --- a/backend/src/BigRegister.Api/Data/SeedData.cs +++ b/backend/src/BigRegister.Api/Data/SeedData.cs @@ -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", diff --git a/backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs b/backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs new file mode 100644 index 0000000..4954b64 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs @@ -0,0 +1,103 @@ +using System.Text.Json; + +namespace BigRegister.Domain.Applications; + +/// +/// The aanvraag lifecycle as a closed union (WP-73): (the pre-submission +/// wizard draft) → (awaiting a behandelaar's decision, or already +/// auto-rejected at submission time — see ) → +/// (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 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 +/// Aanvraag needed one. +/// +/// is the EF-mapped persistence row this maps to/from +/// (Api.Data.AanvraagMapper's ToDomain/ApplyTo/ToEntity) — 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 a screen renders is a further, time-dependent +/// projection (StatusAt, in Api.Data) — 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. +/// +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 DocumentIds { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + + /// 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. + public string? ZaakUrl { get; init; } + + /// WP-60: non-null means the ZGW side of this aanvraag's last write did not + /// complete — see Api.Data.ApplicationStore.SetZgwError. + public string? ZgwError { get; init; } + + private Aanvraag() { } + + /// Pre-submission wizard draft. + public sealed record Concept : Aanvraag + { + public JsonElement? Draft { get; init; } + public int StepIndex { get; } + public int StepCount { get; } + + /// 0 <= <= — the + /// non-strict upper bound, not the strict "<" a wizard's own step cursor uses, because + /// ApplicationStore.CreateConcept's freshly-created row is (StepIndex: 0, StepCount: + /// 0) before the wizard's first draft sync ever runs, and that has to stay constructible. + /// + 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; + } + } + + /// Submitted, no behandelaar decision recorded yet. non-null + /// means SubmissionRules rejected it automatically at submission time (e.g. a manually + /// entered diploma) — terminal in practice (BeoordelingRules.CanDecide 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 . + 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; } + } + + /// A behandelaar's decision (WP-65b/68) — closed by besluit: only + /// / require a toelichting + /// (BeoordelingRules.RequiresToelichting'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. + 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; } + } + } +} diff --git a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs index 484c36e..3d1a37d 100644 --- a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs +++ b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs @@ -5,10 +5,10 @@ namespace BigRegister.Domain.Intake; /// scholing question is required. The frontend receives this value /// (GET /intake/policy) and applies it for instant UX feedback /// (intake.machine.ts's lageUren); is the -/// backend re-validating it as the authority on submit (WP-69) — both -/// POST /applications/{id}/submit (intake-typed aanvragen only) and the legacy -/// POST /intakes call it before writing anything, and a violation 400s -/// (ProblemDetails), never silently accepts an incomplete answer. +/// backend re-validating it as the authority on submit (WP-69) — +/// POST /applications/{id}/submit (intake-typed aanvragen only) calls it before +/// writing anything, and a violation 400s (ProblemDetails), never silently accepts +/// an incomplete answer. /// public static class IntakePolicy { diff --git a/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs b/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs index 9cff934..7527754 100644 --- a/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs +++ b/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs @@ -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}."); } - - /// Invariant: a non-active status must not carry a herregistratie date. - public static bool IsStatusConsistent(RegistrationStatus s) => - s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null; } diff --git a/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs b/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs index 9896b2f..b3d0bad 100644 --- a/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs +++ b/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs @@ -9,15 +9,37 @@ public enum StatusTag } /// -/// Status as a flat record: only 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 carries a herregistratie deadline; only +/// and carry a reden — and there it is +/// required, not nullable (the old flat record left Reden nullable on every tag, +/// diverging from the frontend union, which has always required it on those two variants — +/// see registratie/domain/registration.ts). The private base constructor closes the +/// hierarchy: only the three nested sealed records below can ever inherit from +/// , so a caller can never construct e.g. a +/// with a herregistratie date, or a fourth variant. /// -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, diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 5c23d50..5dd830d 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -189,24 +189,6 @@ api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); -api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) => - Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents)) -.Produces() -.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() -.ProducesProblem(StatusCodes.Status400BadRequest) -.ProducesProblem(StatusCodes.Status422UnprocessableEntity); - api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon))) .Produces() @@ -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))); diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index eda7a34..2780998 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -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($"{options.ZrcBaseUrl}/statussen", diff --git a/backend/swagger.json b/backend/swagger.json index f16fd58..6ca507f 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -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": { diff --git a/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs b/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs index a02b795..807ce5c 100644 --- a/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs +++ b/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs @@ -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. diff --git a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs index c21eb64..d70ee6c 100644 --- a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs +++ b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs @@ -8,13 +8,13 @@ using BigRegister.Tests.Builders; namespace BigRegister.Tests.Acceptance; /// -/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over both live HTTP -/// paths — POST /applications/{id}/submit (the wizard's real path) and the legacy -/// POST /intakes (dead from the UI, still a live crafted-POST surface). Built through -/// the type-state builder, mirroring -/// rather than the full wizard/upload dance — the builder's default owner IS -/// 's default caller, -/// so no header juggling. +/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over +/// POST /applications/{id}/submit (the wizard's real path — WP-72 deleted the legacy +/// POST /intakes endpoint this once also covered). Built through the type-state builder, mirroring rather +/// than the full wizard/upload dance — the builder's default owner IS 's default caller, so +/// no header juggling. /// public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFixture { @@ -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(ApplicationStore.GetAny(aanvraag.Id)); } [Fact] @@ -121,16 +120,4 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi var body = (await res.Content.ReadFromJsonAsync())!; 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); - } } diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs index 5aedd71..551a6ca 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -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); diff --git a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs index 31fd155..812c86a 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs @@ -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); diff --git a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs index ed54c3f..b486b3c 100644 --- a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs +++ b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs @@ -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 } /// -/// Type-state test-data builder for (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 . -/// -/// ponytail: 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 + 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 (WP-70; simplified at WP-73). "Build +/// test data through the same door production code uses" — 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 .Decided(...) to call in the first place, and a missing toelichting is a runtime +/// guard identical to ApplicationStore.RecordBesluit's own. Start at +/// . /// public static class Given { @@ -52,60 +49,50 @@ public sealed class ConceptAanvraag } /// The wizard's current position — step of . - /// Guarded the same way a real cursor is (`STEPS[Math.min(cursor, STEPS.length - 1)]` on the - /// frontend): must be at least 1, and must fall - /// within [0, of)AtStep(9, 2) is not a position any real wizard can reach, so - /// the builder refuses it instead of silently building an impossible fixture. + /// Bounds are 's OWN constructor's to enforce, not this + /// builder's — an out-of-range pair fails at , the same + /// 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 - /// ApplicationStore.Submit), so Aanvraag.StatusAt's Referentie! 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); + /// ApplicationStore.Submit), 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 ). + 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(), CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, }; } /// A submitted aanvraag, open for a behandelaar's decision. The only next step is -/// — there is no way back to ConceptAanvraag. +/// — there is no way back to . 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 } /// Registers this aanvraag's already-known OpenZaak zaak URL — mirrors - /// , the one production writer of this - /// field, so a fixture that needs a pre-existing zaak doesn't reach past Build() to - /// mutate the result by hand. + /// , the one production writer of this field, so a + /// fixture that needs a pre-existing zaak doesn't reach past Build() to mutate the + /// result by hand. public SubmittedAanvraag WithZaakUrl(string zaakUrl) { _zaakUrl = zaakUrl; return this; } - /// Records a behandelaar's decision — reusing , - /// the SAME rule production's besluit endpoint runs, rather than restating it here where it - /// could quietly drift. Throws for an Afwijzen/MeerInfoOpvragen - /// with a null/blank — exactly what that endpoint rejects with - /// a 400, just caught here at fixture-build time instead. - public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null) + /// Records a behandelaar's decision. Unlike the pre-WP-73 builder, there is no + /// hand-written toelichting guard mirroring BeoordelingRules.RequiresToelichting any + /// more — / + /// 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 string? a wire request would carry), same failure production's own + /// ApplicationStore.RecordBesluit raises for the identical input. + 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(), + 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(), + 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(), + 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(), CreatedAt = _submittedAt, UpdatedAt = _submittedAt, ZaakUrl = _zaakUrl, + Referentie = _referentie, + SubmittedAt = _submittedAt, + AutoApprovable = _autoApprovable, }; } -/// A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the -/// builder too — there's nothing past , matching Goedgekeurd/Afgewezen being -/// terminal in the domain (); a fixture that needs a -/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from -/// again, exactly as a real second request would. -public sealed class DecidedAanvraag +/// A submitted aanvraag with a behandelaar's decision already recorded — terminal in +/// the builder too, matching Goedgekeurd/Afgewezen being terminal in the domain +/// (); a fixture that +/// needs a SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from +/// again, exactly as a real second request would. Just a one-line +/// wrapper around the already-fully-built value — WP-73 moved +/// all the actual construction (and its invariant enforcement) into +/// itself, so there's nothing left for this type to do +/// except keep .Decided(...).Build() a valid two-call chain for the existing test +/// suite. +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; } diff --git a/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs index f9a6c95..8c263a6 100644 --- a/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs +++ b/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs @@ -1,3 +1,4 @@ +using BigRegister.Api.Data; using BigRegister.Domain.Applications; using BigRegister.Domain.Beoordeling; using BigRegister.Tests.Builders; diff --git a/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs index 091b6e0..ef12013 100644 --- a/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs +++ b/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs @@ -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))); - } } diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index f851844..d7335a1 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -89,27 +89,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(null as any); } - /** - * @return OK - */ - herregistraties(body: HerregistratieRequest): Promise { - 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 { - 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(null as any); - } - - /** - * @return OK - */ - intakes(body: IntakeRequest): Promise { - 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 { - 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(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;