Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868fb55783 | ||
|
|
472a49f19f | ||
|
|
31d4aa1848 | ||
|
|
fc6e73806a | ||
|
|
fd04221d2f | ||
|
|
a394950a1d | ||
|
|
6a4a0ad435 | ||
|
|
6cfd70eeeb |
@@ -50,7 +50,7 @@ dotnet test --filter Category=Integration
|
|||||||
`OpenZaakIntegrationTests.cs` points a `WebApplicationFactory<Program>` at
|
`OpenZaakIntegrationTests.cs` points a `WebApplicationFactory<Program>` at
|
||||||
`Zgw:Enabled=true` + `http://localhost:8000` with the harness's credentials, hits
|
`Zgw:Enabled=true` + `http://localhost:8000` with the harness's credentials, hits
|
||||||
`GET /api/v1/admin/cases`, and asserts the seeded zaak comes back — through the real HTTP +
|
`GET /api/v1/admin/cases`, and asserts the seeded zaak comes back — through the real HTTP +
|
||||||
JWT + Catalogi-label-resolution path, not a mock. This test is tagged `Category=Integration`
|
JWT + zaaktype→aanvraag-type mapping path, not a mock. This test is tagged `Category=Integration`
|
||||||
and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
|
and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
|
||||||
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
|
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
|
||||||
up, so it never runs where the harness doesn't exist.
|
up, so it never runs where the harness doesn't exist.
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ print(json.dumps({
|
|||||||
echo " created: $zaaktype_url"
|
echo " created: $zaaktype_url"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses..."
|
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for WP-66's besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..."
|
||||||
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"]' \
|
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \
|
||||||
"zaaktype=\"$zaaktype_url\"" \
|
"zaaktype=\"$zaaktype_url\"" \
|
||||||
'max_vertrouwelijkheidaanduiding="openbaar"'
|
'max_vertrouwelijkheidaanduiding="openbaar"'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Diplomas;
|
using BigRegister.Domain.Diplomas;
|
||||||
using BigRegister.Domain.Documents;
|
using BigRegister.Domain.Documents;
|
||||||
using BigRegister.Domain.People;
|
using BigRegister.Domain.People;
|
||||||
@@ -36,31 +37,17 @@ public static class Mappers
|
|||||||
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
||||||
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
|
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
|
||||||
|
|
||||||
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
/// <summary>Wire projection of an <see cref="AanvraagStatus"/> — the null "Concept" case
|
||||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
/// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire
|
||||||
// stays In behandeling until a behandelaar records a decision (WP-65b — before that
|
/// convention's magic string here at the boundary rather than living inside the domain type.
|
||||||
// WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure —
|
/// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers
|
||||||
// testable by passing different `now` values without waiting for the wall clock.
|
/// agree on the projection (WP-68 F3).</summary>
|
||||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
|
||||||
{
|
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
|
||||||
if (!a.Submitted)
|
|
||||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
// Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line
|
||||||
if (a.Reden is not null)
|
// projection of that domain method onto the wire DTO (WP-68 F3).
|
||||||
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
|
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
|
||||||
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
|
|
||||||
// behandelaar's explicit besluit is authoritative once made.
|
|
||||||
if (a.BesluitStatus is { } besluit)
|
|
||||||
return besluit switch
|
|
||||||
{
|
|
||||||
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
|
|
||||||
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
|
|
||||||
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
|
|
||||||
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
|
|
||||||
};
|
|
||||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
|
||||||
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
|
|
||||||
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||||
|
|||||||
@@ -1,31 +1,15 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
|
using BigRegister.Domain.Beoordeling;
|
||||||
using BigRegister.Domain.Submissions;
|
using BigRegister.Domain.Submissions;
|
||||||
|
|
||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
|
|
||||||
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
|
|
||||||
/// the wizard draft) isn't part of this enum — see <see cref="Aanvraag.Submitted"/>.
|
|
||||||
/// <see cref="Ingediend"/> and <see cref="MeerInfoGevraagd"/> are not reachable yet: no
|
|
||||||
/// endpoint sets them (that's WP-65's behandelaar-facing mutation) — modelled here so the
|
|
||||||
/// contract is ready when it does.
|
|
||||||
/// </summary>
|
|
||||||
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
|
|
||||||
/// offers, each advancing <see cref="Aanvraag.BesluitStatus"/> and (via
|
|
||||||
/// <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/>) the published
|
|
||||||
/// <see cref="AanvraagStatusTag"/> the FE renders.
|
|
||||||
/// </summary>
|
|
||||||
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
|
/// 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,
|
/// 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
|
/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen
|
||||||
/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see Mappers.ToStatusDto) so
|
/// 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.
|
/// auto-approval is purely a function of stored timestamps — no timers, no jobs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Aanvraag
|
public sealed class Aanvraag
|
||||||
@@ -69,6 +53,26 @@ public sealed class Aanvraag
|
|||||||
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
|
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
|
||||||
/// the published status's Reden), optional for Goedkeuren.</summary>
|
/// the published status's Reden), optional for Goedkeuren.</summary>
|
||||||
public string? BesluitToelichting { get; set; }
|
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>
|
/// <summary>
|
||||||
@@ -134,6 +138,21 @@ public static class ApplicationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cross-owner lookup by Referentie — real bug fix (WP-66): the behandelaar besluit
|
||||||
|
/// endpoint receives the FE-facing case id from <c>IZaakSource.ListCases</c>, which under
|
||||||
|
/// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only
|
||||||
|
/// <c>LocalZaakSource</c>'s id happens to already be the Aanvraag.Id — every besluit 404'd
|
||||||
|
/// against a real OpenZaak). Referentie is the one identifier stable across both sources —
|
||||||
|
/// it's also what <c>CreateZaak</c> sent OpenZaak as <c>identificatie</c>.
|
||||||
|
public static Aanvraag? GetByReferentie(string referentie)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.Applications.FirstOrDefault(a => a.Referentie == referentie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
|
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
|
||||||
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
|
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
|
||||||
public static IReadOnlyList<Aanvraag> ListAll()
|
public static IReadOnlyList<Aanvraag> ListAll()
|
||||||
@@ -255,23 +274,30 @@ public static class ApplicationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Record a behandelaar's decision (WP-65b). The endpoint has already checked
|
public enum RecordBesluitOutcome { Ok, NotFound, Conflict }
|
||||||
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/> against the
|
|
||||||
/// freshly-read status before calling this — cross-owner like <see cref="DeleteAny"/>,
|
/// <summary>Record a behandelaar's decision (WP-65b) — cross-owner like
|
||||||
/// since a behandelaar decides on any citizen's case. Returns null only if the aanvraag
|
/// <see cref="DeleteAny"/>, since a behandelaar decides on any citizen's case.
|
||||||
/// is gone (shouldn't happen — this runs right after the endpoint's own read found it).</summary>
|
/// WP-68 (F2): the transition-legality check (<see cref="BeoordelingRules.CanDecide"/>)
|
||||||
public static Aanvraag? RecordBesluit(string id, Besluit besluit, string? toelichting)
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
using var db = Db.Create();
|
using var db = Db.Create();
|
||||||
var a = db.Applications.Find(id);
|
var a = db.Applications.Find(id);
|
||||||
if (a is null) return null;
|
if (a is null) return (RecordBesluitOutcome.NotFound, null);
|
||||||
|
var current = a.StatusAt(now).Tag;
|
||||||
|
if (current is null || !BeoordelingRules.CanDecide(current.Value))
|
||||||
|
return (RecordBesluitOutcome.Conflict, null);
|
||||||
a.BesluitStatus = besluit;
|
a.BesluitStatus = besluit;
|
||||||
a.BesluitToelichting = toelichting;
|
a.BesluitToelichting = toelichting;
|
||||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
return a;
|
return (RecordBesluitOutcome.Ok, a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,22 @@ public static class DocumentStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which of the given ids do NOT resolve to a document owned by <paramref name="owner"/>
|
||||||
|
/// (unknown id or owned by someone else) — named for what it returns (the offending ids), so a
|
||||||
|
/// caller can 400 with the specific ids rather than a bare boolean. Guards submit/draft-sync
|
||||||
|
/// against a citizen attaching another citizen's upload to their own aanvraag.</summary>
|
||||||
|
public static IReadOnlyList<string> ForeignIds(IEnumerable<string> documentIds, string owner)
|
||||||
|
{
|
||||||
|
var ids = documentIds.ToList();
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
var owned = db.Documents.Where(d => ids.Contains(d.DocumentId) && d.Owner == owner)
|
||||||
|
.Select(d => d.DocumentId).ToHashSet();
|
||||||
|
return ids.Where(id => !owned.Contains(id)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
|
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
|
||||||
public static void SetDrcUrl(string documentId, string drcUrl)
|
public static void SetDrcUrl(string documentId, string drcUrl)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
|
||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
|
||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace BigRegister.Domain.Applications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
|
||||||
|
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
|
||||||
|
/// the wizard draft) is deliberately NOT a member here — see <see cref="AanvraagStatus.Tag"/>,
|
||||||
|
/// which is null exactly when the aanvraag hasn't been submitted yet, instead of a sixth
|
||||||
|
/// "magic string" tag with no enum member to match it (WP-68 F3).
|
||||||
|
/// <see cref="Ingediend"/> is reserved: no endpoint sets it yet (there is no state between
|
||||||
|
/// "just submitted" and "in behandeling" in this POC) — kept because the FE's status union
|
||||||
|
/// and $localize catalogue already declare it, and removing it would ripple into both.
|
||||||
|
/// </summary>
|
||||||
|
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
|
||||||
|
/// offers, each advancing an aanvraag's <see cref="AanvraagStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The domain projection of an aanvraag's status at a point in time (WP-68 F3) — the type
|
||||||
|
/// <c>Aanvraag.StatusAt(now)</c> returns, replacing the logic that used to live directly in
|
||||||
|
/// <c>Contracts.Mappers.ToStatusDto</c>. Constructible only via the factories below, so a
|
||||||
|
/// caller can never build e.g. a Referentie-less Goedgekeurd. <see cref="Tag"/> is null only
|
||||||
|
/// for <see cref="Concept"/> — the one construction path that used to be a bare "Concept"
|
||||||
|
/// string with no corresponding <see cref="AanvraagStatusTag"/> member.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AanvraagStatus
|
||||||
|
{
|
||||||
|
public AanvraagStatusTag? Tag { get; }
|
||||||
|
public int? StepIndex { get; }
|
||||||
|
public int? StepCount { get; }
|
||||||
|
public string? Referentie { get; }
|
||||||
|
public bool? Manual { get; }
|
||||||
|
public string? Reden { get; }
|
||||||
|
|
||||||
|
private AanvraagStatus(AanvraagStatusTag? tag, int? stepIndex, int? stepCount, string? referentie, bool? manual, string? reden)
|
||||||
|
{
|
||||||
|
Tag = tag;
|
||||||
|
StepIndex = stepIndex;
|
||||||
|
StepCount = stepCount;
|
||||||
|
Referentie = referentie;
|
||||||
|
Manual = manual;
|
||||||
|
Reden = reden;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AanvraagStatus Concept(int stepIndex, int stepCount) =>
|
||||||
|
new(null, stepIndex, stepCount, null, null, null);
|
||||||
|
|
||||||
|
public static AanvraagStatus InBehandeling(string referentie, bool manual) =>
|
||||||
|
new(AanvraagStatusTag.InBehandeling, null, null, referentie, manual, null);
|
||||||
|
|
||||||
|
public static AanvraagStatus Goedgekeurd(string referentie) =>
|
||||||
|
new(AanvraagStatusTag.Goedgekeurd, null, null, referentie, null, null);
|
||||||
|
|
||||||
|
public static AanvraagStatus Afgewezen(string referentie, string? reden) =>
|
||||||
|
new(AanvraagStatusTag.Afgewezen, null, null, referentie, null, reden);
|
||||||
|
|
||||||
|
public static AanvraagStatus MeerInfoGevraagd(string referentie, string? reden) =>
|
||||||
|
new(AanvraagStatusTag.MeerInfoGevraagd, null, null, referentie, null, reden);
|
||||||
|
}
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
using BigRegister.Api.Data;
|
using BigRegister.Domain.Applications;
|
||||||
|
|
||||||
namespace BigRegister.Domain.Beoordeling;
|
namespace BigRegister.Domain.Beoordeling;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side
|
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Used from
|
||||||
/// today (<see cref="CanDecide"/> only, backing the beoordeling detail screen's decision
|
/// both the beoordeling read side (<see cref="CanDecide"/> backs the `canBesluiten` decision
|
||||||
/// flag) — the decision-recording rules (which besluit is legal, whether it needs a
|
/// flag) and the besluit write side (the SAME `CanDecide` gates the mutation, and — since
|
||||||
/// toelichting) land alongside the mutation endpoint in this WP's second half.
|
/// WP-68 F2 — runs inside the write lock, so the two can never drift and a concurrent besluit
|
||||||
|
/// can't race past the check).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BeoordelingRules
|
public static class BeoordelingRules
|
||||||
{
|
{
|
||||||
/// A behandelaar may record a decision while the aanvraag is in an open, non-terminal
|
/// A behandelaar may record a decision while the aanvraag is in an open, non-terminal
|
||||||
/// status. Concept never reaches here (the endpoint 404s it before calling this); a case
|
/// status. Concept never reaches here (a null <see cref="AanvraagStatus.Tag"/> is checked
|
||||||
/// already `Goedgekeurd`/`Afgewezen` is final.
|
/// separately by callers); a case already `Goedgekeurd`/`Afgewezen` is final.
|
||||||
public static bool CanDecide(AanvraagStatusTag current) =>
|
public static bool CanDecide(AanvraagStatusTag current) =>
|
||||||
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
|
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
|
||||||
or AanvraagStatusTag.MeerInfoGevraagd;
|
or AanvraagStatusTag.MeerInfoGevraagd;
|
||||||
|
|
||||||
|
/// <summary>WP-68 F6: moved here from an inline check in the besluit endpoint. The
|
||||||
|
/// toelichting (behandelaar's explanation) is required for every besluit except an
|
||||||
|
/// approval — Afwijzen/MeerInfoOpvragen must justify why (becomes the published status's
|
||||||
|
/// Reden).</summary>
|
||||||
|
public static bool RequiresToelichting(Besluit besluit) => besluit != Besluit.Goedkeuren;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
namespace BigRegister.Domain.Intake;
|
namespace BigRegister.Domain.Intake;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
|
/// Config value (ADR-0001's "config value" shape). Below this many NL work-hours the
|
||||||
/// is required. The frontend receives this value and applies it for instant UX
|
/// scholing question is required. The frontend receives this value
|
||||||
/// feedback, but the backend re-validates on submit as the authority.
|
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
|
||||||
|
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>).
|
||||||
|
///
|
||||||
|
/// WP-68 (F5): the class doc used to claim "the backend re-validates on submit as the
|
||||||
|
/// authority" — it doesn't. Neither <c>SubmitApplicationRequest</c> nor <c>IntakeRequest</c>
|
||||||
|
/// carries a scholing answer at all, so there is nothing for the server to re-validate;
|
||||||
|
/// both submit paths only apply <c>SubmissionRules.RejectZeroUren</c>. A crafted POST can
|
||||||
|
/// bypass the scholing requirement entirely. Enforcing this needs a wire change (the
|
||||||
|
/// request DTOs must carry the wizard's scholing answer) and is deferred to WP-69 — this
|
||||||
|
/// comment states the gap rather than a false guarantee.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class IntakePolicy
|
public static class IntakePolicy
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
using BigRegister.Domain.Beoordeling;
|
using BigRegister.Domain.Beoordeling;
|
||||||
using BigRegister.Domain.Diplomas;
|
using BigRegister.Domain.Diplomas;
|
||||||
@@ -314,9 +315,19 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
|
|||||||
|
|
||||||
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
|
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
|
||||||
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
|
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
|
||||||
ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
|
{
|
||||||
? Results.NoContent() : Results.NotFound())
|
var owner = ctx.Zorgverlener().Bsn;
|
||||||
|
// A citizen may only reference their own uploads in a draft — reject before the sync
|
||||||
|
// writes a foreign document id into the aanvraag (ADR-0001: the FE holds no authority).
|
||||||
|
if (req.DocumentIds is { } ids && DocumentStore.ForeignIds(ids, owner) is { Count: > 0 } foreign)
|
||||||
|
return Results.Problem(
|
||||||
|
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreign)}.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
return ApplicationStore.SyncDraft(id, owner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
|
||||||
|
? Results.NoContent() : Results.NotFound();
|
||||||
|
})
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
|
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
|
||||||
@@ -353,6 +364,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
|||||||
var docs = req.Documents;
|
var docs = req.Documents;
|
||||||
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||||
|
|
||||||
|
// A citizen may only submit their own uploads — reject before the submit writes a
|
||||||
|
// foreign document id onto the aanvraag (ADR-0001: the FE holds no authority).
|
||||||
|
if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds)
|
||||||
|
return Results.Problem(
|
||||||
|
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreignIds)}.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds);
|
var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds);
|
||||||
if (submitted is null) return Results.Conflict();
|
if (submitted is null) return Results.Conflict();
|
||||||
|
|
||||||
@@ -401,6 +419,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
|||||||
return Results.Ok(new SubmitApplicationResponse(referentie, status));
|
return Results.Ok(new SubmitApplicationResponse(referentie, status));
|
||||||
})
|
})
|
||||||
.Produces<SubmitApplicationResponse>()
|
.Produces<SubmitApplicationResponse>()
|
||||||
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
@@ -434,8 +453,10 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
|||||||
var docs = DocumentStore.ByIds(c.DocumentIds)
|
var docs = DocumentStore.ByIds(c.DocumentIds)
|
||||||
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
|
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
|
||||||
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
|
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
|
||||||
var decisions = new BeoordelingDecisionsDto(
|
// WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an
|
||||||
BeoordelingRules.CanDecide(Enum.Parse<AanvraagStatusTag>(c.Status.Tag)));
|
// unrecognised tag degrades to "cannot decide" instead of a 500.
|
||||||
|
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
|
||||||
|
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
||||||
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
||||||
}))
|
}))
|
||||||
.Produces<BeoordelingViewDto>()
|
.Produces<BeoordelingViewDto>()
|
||||||
@@ -446,43 +467,52 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
|||||||
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
|
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
|
||||||
// seam) — same reasoning as the GET above. The transition-legality check
|
// seam) — same reasoning as the GET above. The transition-legality check
|
||||||
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
|
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
|
||||||
// so the two can never drift. WP-66: once the local decision has committed, IZaakSource
|
// so the two can never drift — and (WP-68 F2) it now runs inside ApplicationStore.RecordBesluit's
|
||||||
// also gets a chance to advance the ZGW-side zaak status — LocalZaakSource no-ops,
|
// write lock rather than here, so two concurrent besluiten can't both pass it before either
|
||||||
// OpenZaakZaakSource POSTs a new Statussen entry (see its RecordBesluit).
|
// writes. WP-66: once the local decision has committed, IZaakSource also gets a chance to
|
||||||
|
// advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new
|
||||||
|
// Statussen entry (see its RecordBesluit).
|
||||||
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
|
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
|
||||||
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
|
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
|
||||||
{
|
{
|
||||||
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
|
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
|
||||||
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
|
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
// WP-68 (F6): moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable.
|
||||||
|
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting))
|
||||||
|
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var a = ApplicationStore.GetAny(id);
|
// Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under
|
||||||
var statusTag = a?.ToStatusDto(now).Tag;
|
// OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a
|
||||||
if (a is null || statusTag == "Concept") return Results.NotFound();
|
// ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above
|
||||||
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
|
// uses), so resolve the case first and go to the local Aanvraag via its Referentie
|
||||||
if (!BeoordelingRules.CanDecide(current))
|
// (see ApplicationStore.GetByReferentie).
|
||||||
|
var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id);
|
||||||
|
var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null;
|
||||||
|
if (a is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var (outcome, updated) = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting, now);
|
||||||
|
if (outcome == ApplicationStore.RecordBesluitOutcome.NotFound) return Results.NotFound();
|
||||||
|
if (outcome == ApplicationStore.RecordBesluitOutcome.Conflict)
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
|
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
|
||||||
statusCode: StatusCodes.Status409Conflict);
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
|
|
||||||
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
|
|
||||||
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
|
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
|
||||||
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
|
|
||||||
|
|
||||||
// WP-60: the local decision above already committed — a ZGW failure here is caught and
|
// WP-60: the local decision above already committed — a ZGW failure here is caught and
|
||||||
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak
|
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak
|
||||||
// and document-link writes.
|
// and document-link writes.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller());
|
zaken.RecordBesluit(updated!, besluit, req.Toelichting, now, ctx.Caller());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex);
|
RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
|
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
|
||||||
}))
|
}))
|
||||||
.Produces<RecordBesluitResponse>()
|
.Produces<RecordBesluitResponse>()
|
||||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
|
||||||
namespace BigRegister.Api.Zgw;
|
namespace BigRegister.Api.Zgw;
|
||||||
@@ -15,14 +16,17 @@ public sealed record ZgwPage<T>(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50
|
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50
|
||||||
/// write). Reads zaken (following pagination), resolves each zaaktype's human label from the
|
/// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
|
||||||
/// Catalogi API (cached), and maps into <see cref="ApplicationSummaryDto"/> via
|
/// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
|
||||||
/// <see cref="ZgwZaakMapper"/>. Creates a zaak + status + rol for a just-submitted aanvraag.
|
/// human zaaktype label, which isn't a value <see cref="ApplicationSummaryDto.Type"/>'s
|
||||||
/// Selected only when <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalZaakSource"/>.
|
/// contract accepts; see <see cref="AanvraagTypeFor"/>), and maps into
|
||||||
|
/// <see cref="ApplicationSummaryDto"/> via <see cref="ZgwZaakMapper"/>. Creates a zaak +
|
||||||
|
/// status + rol for a just-submitted aanvraag. Selected only when <c>Zgw:Enabled=true</c>;
|
||||||
|
/// the default stays <see cref="LocalZaakSource"/>.
|
||||||
///
|
///
|
||||||
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization
|
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization
|
||||||
/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution);
|
/// header. Creating/deciding a zaak needs read scope on Catalogi too (statustype/resultaattype/
|
||||||
/// creating one additionally needs write scope on Zaken.
|
/// roltype resolution) in addition to write scope on Zaken; a plain read does not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
|
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
|
||||||
{
|
{
|
||||||
@@ -47,17 +51,22 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
|||||||
if (bsn is not null)
|
if (bsn is not null)
|
||||||
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
|
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
|
||||||
var zaken = await GetAllAsync<ZgwZaak>(url, caller);
|
var zaken = await GetAllAsync<ZgwZaak>(url, caller);
|
||||||
var labels = new Dictionary<string, string>();
|
return zaken.Select(z => ZgwZaakMapper.ToSummaryDto(z, AanvraagTypeFor(z.Zaaktype))).ToList();
|
||||||
var result = new List<ApplicationSummaryDto>(zaken.Count);
|
|
||||||
foreach (var z in zaken)
|
|
||||||
{
|
|
||||||
if (!labels.TryGetValue(z.Zaaktype, out var label))
|
|
||||||
labels[z.Zaaktype] = label = await ZaaktypeLabelAsync(z.Zaaktype);
|
|
||||||
result.Add(ZgwZaakMapper.ToSummaryDto(z, label));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse):
|
||||||
|
/// <c>ApplicationSummaryDto.Type</c>'s contract is the internal aanvraag-type key (e.g.
|
||||||
|
/// "herregistratie" — what <see cref="LocalZaakSource"/>/<c>Mappers.ToSummaryDto</c> send,
|
||||||
|
/// and what the FE's <c>AANVRAAG_TYPES</c> trust boundary accepts), NOT OpenZaak's human
|
||||||
|
/// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round
|
||||||
|
/// trip — every case failed the FE's parse boundary as soon as a real OpenZaak backed this
|
||||||
|
/// seam. A zaak's zaaktype URL round-trips back to that key via the same
|
||||||
|
/// <c>Zgw:ZaaktypeUrls</c> config <see cref="CreateZaakAsync"/> goes the other way with —
|
||||||
|
/// no Catalogi call needed, and no label cache either.</summary>
|
||||||
|
private string AanvraagTypeFor(string zaaktypeUrl) =>
|
||||||
|
options.ZaaktypeUrls.FirstOrDefault(kv => kv.Value == zaaktypeUrl).Key
|
||||||
|
?? throw new InvalidOperationException($"No aanvraag type configured for zaaktype {zaaktypeUrl}.");
|
||||||
|
|
||||||
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
|
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
|
||||||
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
|
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
|
||||||
{
|
{
|
||||||
@@ -72,13 +81,6 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary>
|
|
||||||
private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl)
|
|
||||||
{
|
|
||||||
var zt = await zgw.GetAsync<Zaaktype>(zaaktypeUrl);
|
|
||||||
return zt.Omschrijving;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
|
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
|
||||||
|
|
||||||
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
|
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
|
||||||
@@ -152,7 +154,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
|||||||
/// WP-60: no compensating transaction here either — the local decision already committed
|
/// WP-60: no compensating transaction here either — the local decision already committed
|
||||||
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
|
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
|
||||||
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
|
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
|
||||||
/// the same way the submit endpoint's create-zaak/document writes are.</summary>
|
/// the same way the submit endpoint's create-zaak/document writes are.
|
||||||
|
///
|
||||||
|
/// ZGW requires a zaak to have a Resultaat before it can reach an eindstatus (OpenZaak 400s
|
||||||
|
/// "Zaak has no resultaat" otherwise — confirmed against a real instance) — so this posts one
|
||||||
|
/// first, same "existence-only, take the first" resolution as the statustype above (the
|
||||||
|
/// harness's catalogus provisions exactly one resultaattype per zaaktype, not one per besluit
|
||||||
|
/// outcome; a real deployment mapping besluit → resultaattype is future work).</summary>
|
||||||
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
|
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
|
||||||
RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
|
RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
|
||||||
|
|
||||||
@@ -163,12 +171,25 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
|
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
|
||||||
|
|
||||||
|
var resultaattypeUrl = await FirstResultaattypeUrlAsync(zaaktypeUrl);
|
||||||
|
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/resultaten",
|
||||||
|
new CreateResultaatRequest(aanvraag.ZaakUrl, resultaattypeUrl), caller);
|
||||||
|
|
||||||
var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl);
|
var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl);
|
||||||
var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
|
var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
|
||||||
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
|
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
|
||||||
aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
|
aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> FirstResultaattypeUrlAsync(string zaaktypeUrl)
|
||||||
|
{
|
||||||
|
var page = await zgw.GetAsync<ZgwPage<Resultaattype>>(
|
||||||
|
$"{options.ZtcBaseUrl}/resultaattypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
|
||||||
|
var first = page.Results.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException($"No resultaattype found for zaaktype {zaaktypeUrl}.");
|
||||||
|
return first.Url;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer
|
/// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer
|
||||||
/// (the eind status) rather than lowest.</summary>
|
/// (the eind status) rather than lowest.</summary>
|
||||||
private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl)
|
private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl)
|
||||||
@@ -189,14 +210,14 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
|||||||
return first.Url;
|
return first.Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
|
|
||||||
|
|
||||||
private sealed record Statustype(
|
private sealed record Statustype(
|
||||||
[property: JsonPropertyName("url")] string Url,
|
[property: JsonPropertyName("url")] string Url,
|
||||||
[property: JsonPropertyName("volgnummer")] int Volgnummer);
|
[property: JsonPropertyName("volgnummer")] int Volgnummer);
|
||||||
|
|
||||||
private sealed record Roltype([property: JsonPropertyName("url")] string Url);
|
private sealed record Roltype([property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
|
private sealed record Resultaattype([property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
private sealed record CreateZaakRequest(
|
private sealed record CreateZaakRequest(
|
||||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||||
@@ -210,6 +231,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
|||||||
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
|
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
|
||||||
[property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
|
[property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
|
||||||
|
|
||||||
|
private sealed record CreateResultaatRequest(
|
||||||
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
|
[property: JsonPropertyName("resultaattype")] string Resultaattype);
|
||||||
|
|
||||||
private sealed record CreateRolRequest(
|
private sealed record CreateRolRequest(
|
||||||
[property: JsonPropertyName("zaak")] string Zaak,
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
|
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
|
|
||||||
namespace BigRegister.Api.Zgw;
|
namespace BigRegister.Api.Zgw;
|
||||||
|
|
||||||
@@ -33,9 +34,9 @@ public static class ZgwZaakMapper
|
|||||||
// ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
|
// ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
|
||||||
// one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
|
// one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
|
||||||
// Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end.
|
// Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end.
|
||||||
var status = z.Einddatum is null
|
var status = (z.Einddatum is null
|
||||||
? new AanvraagStatusDto("InBehandeling", Referentie: z.Identificatie, Manual: true)
|
? AanvraagStatus.InBehandeling(z.Identificatie, manual: true)
|
||||||
: new AanvraagStatusDto("Goedgekeurd", Referentie: z.Identificatie);
|
: AanvraagStatus.Goedgekeurd(z.Identificatie)).ToDto();
|
||||||
|
|
||||||
var created = Iso(z.Registratiedatum ?? z.Startdatum);
|
var created = Iso(z.Registratiedatum ?? z.Startdatum);
|
||||||
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
|
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
|
||||||
@@ -59,5 +60,5 @@ public static class ZgwZaakMapper
|
|||||||
/// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling
|
/// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling
|
||||||
/// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary>
|
/// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary>
|
||||||
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
|
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
|
||||||
new("InBehandeling", Referentie: identificatie, Manual: true);
|
AanvraagStatus.InBehandeling(identificatie, manual: true).ToDto();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -643,6 +643,16 @@
|
|||||||
"204": {
|
"204": {
|
||||||
"description": "No Content"
|
"description": "No Content"
|
||||||
},
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Not Found"
|
"description": "Not Found"
|
||||||
}
|
}
|
||||||
@@ -718,6 +728,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"409": {
|
"409": {
|
||||||
"description": "Conflict",
|
"description": "Conflict",
|
||||||
"content": {
|
"content": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
namespace BigRegister.Tests;
|
namespace BigRegister.Tests;
|
||||||
@@ -172,6 +173,51 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WP-68 (F1): a citizen may only reference their own uploads — submit/draft-sync must
|
||||||
|
// reject a foreign documentId rather than silently attaching it. ---
|
||||||
|
|
||||||
|
private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId)
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
||||||
|
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||||
|
content.Add(file, "file", "d.pdf");
|
||||||
|
content.Add(new StringContent("diploma"), "categoryId");
|
||||||
|
content.Add(new StringContent(localId), "localId");
|
||||||
|
content.Add(new StringContent("registratie"), "wizardId");
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/uploads") { Content = content, Headers = { { "X-Subject", owner } } };
|
||||||
|
var res = await client.SendAsync(req);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||||
|
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner()
|
||||||
|
{
|
||||||
|
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
|
||||||
|
var a = await Create("registratie");
|
||||||
|
|
||||||
|
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit",
|
||||||
|
new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||||
|
|
||||||
|
// The rejected submit must not have flipped the foreign document's Linked flag — its
|
||||||
|
// owner can still delete it.
|
||||||
|
var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/uploads/{foreignDoc.DocumentId}") { Headers = { { "X-Subject", "999888777" } } };
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(deleteReq)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Draft_sync_with_a_foreign_documentId_is_rejected()
|
||||||
|
{
|
||||||
|
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
|
||||||
|
var a = await Create("registratie");
|
||||||
|
|
||||||
|
var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||||
|
new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
||||||
|
|
||||||
private static Aanvraag Accepted(bool autoApprovable) => new()
|
private static Aanvraag Accepted(bool autoApprovable) => new()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
|
using BigRegister.Domain.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>Wraps <see cref="LocalZaakSource"/> but returns a DIFFERENT case id than the
|
||||||
|
/// underlying Aanvraag.Id — reproduces exactly what <c>OpenZaakZaakSource</c> does in
|
||||||
|
/// production (the FE-facing case id from <c>ListCases</c> is the ZGW zaak's own uuid, not
|
||||||
|
/// <c>ApplicationStore</c>'s primary key) without needing a live OpenZaak, so the besluit
|
||||||
|
/// endpoint's Referentie-based resolution (the fix below) gets coverage on every push.</summary>
|
||||||
|
file sealed class IdMismatchZaakSource : IZaakSource
|
||||||
|
{
|
||||||
|
private readonly LocalZaakSource inner = new();
|
||||||
|
private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" };
|
||||||
|
|
||||||
|
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
|
||||||
|
inner.ListCases(now).Select(Rekey).ToList();
|
||||||
|
|
||||||
|
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
|
||||||
|
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);
|
||||||
|
|
||||||
|
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
|
||||||
|
inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression for a real, live-repro'd bug: recording a besluit from the behandelportal always
|
||||||
|
/// 404'd against a real OpenZaak. Root cause — <c>POST /beoordeling/{id}/besluit</c> looked
|
||||||
|
/// <c>id</c> up directly in <c>ApplicationStore</c> (its own primary key), but <c>id</c> is
|
||||||
|
/// whatever <c>IZaakSource.ListCases</c> handed the FE; under <c>OpenZaakZaakSource</c> that's
|
||||||
|
/// the ZGW zaak's own uuid, a different value entirely. Fixed by resolving the case through
|
||||||
|
/// the same <c>ListCases</c> seak the GET sibling (<see cref="BeoordelingTests"/>) already uses,
|
||||||
|
/// then to the local <c>Aanvraag</c> via its Referentie (<c>ApplicationStore.GetByReferentie</c>)
|
||||||
|
/// — the one identifier stable across both sources. <see cref="IdMismatchZaakSource"/>
|
||||||
|
/// reproduces the id divergence without a live OpenZaak.
|
||||||
|
/// </summary>
|
||||||
|
public class BeoordelingIdMismatchTests
|
||||||
|
{
|
||||||
|
private static WebApplicationFactory<Program> Factory()
|
||||||
|
{
|
||||||
|
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-id-mismatch-{Guid.NewGuid():N}.db");
|
||||||
|
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
||||||
|
.UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}")
|
||||||
|
.ConfigureTestServices(services => services.AddSingleton<IZaakSource, IdMismatchZaakSource>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage Behandelaar(HttpMethod method, string path, object? body = null)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, path);
|
||||||
|
req.Headers.Add("X-Medewerker", "medewerker-1");
|
||||||
|
if (body is not null) req.Content = JsonContent.Create(body);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Besluit_resolves_by_referentie_when_the_case_id_differs_from_the_local_aanvraag_id()
|
||||||
|
{
|
||||||
|
using var factory = Factory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
||||||
|
var app = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||||
|
var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
||||||
|
submit.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad"));
|
||||||
|
var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
|
||||||
|
var caseId = Assert.Single(items).Id;
|
||||||
|
// Sanity: the id divergence this test exists for is real, not accidentally absent.
|
||||||
|
Assert.NotEqual(app.Id, caseId);
|
||||||
|
|
||||||
|
var res = await client.SendAsync(Behandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{caseId}/besluit",
|
||||||
|
new { besluit = "Afwijzen", toelichting = "onvolledig" }));
|
||||||
|
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
||||||
|
Assert.Equal("Afgewezen", body.Status.Tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,6 +215,35 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WP-68 (F2): the transition-legality check now runs inside RecordBesluit's write lock, so
|
||||||
|
// two besluiten racing on the same still-open aanvraag can't both pass the check before
|
||||||
|
// either writes — exactly one commits, the other sees the now-terminal status.
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success()
|
||||||
|
{
|
||||||
|
var (a, _) = await CreateManualCaseWithDocument();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await Task.WhenAll(
|
||||||
|
PostBesluit(a.Id, new { besluit = "Goedkeuren" }),
|
||||||
|
PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" }));
|
||||||
|
|
||||||
|
var winner = Assert.Single(results, r => r.StatusCode == HttpStatusCode.OK);
|
||||||
|
Assert.Single(results, r => r.StatusCode == HttpStatusCode.Conflict);
|
||||||
|
|
||||||
|
// The persisted outcome must match whichever request actually won the race, not just
|
||||||
|
// "some" besluit — the loser's write must never have landed.
|
||||||
|
var winningTag = (await winner.Content.ReadFromJsonAsync<RecordBesluitResponse>())!.Status.Tag;
|
||||||
|
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
||||||
|
var finalTag = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!.Aanvraag.Status.Tag;
|
||||||
|
Assert.Equal(winningTag, finalTag);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteAsAdmin(a.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
|
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Zgw;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
@@ -7,7 +9,7 @@ namespace BigRegister.Tests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted,
|
/// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted,
|
||||||
/// real response shapes, real pagination/zaaktype resolution — rather than the stub
|
/// real response shapes, real pagination/zaaktype→aanvraag-type mapping — rather than the stub
|
||||||
/// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>,
|
/// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>,
|
||||||
/// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c>
|
/// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c>
|
||||||
/// to be up and seeded first (see its README); tagged Category=Integration so it's excluded
|
/// to be up and seeded first (see its README); tagged Category=Integration so it's excluded
|
||||||
@@ -23,7 +25,7 @@ namespace BigRegister.Tests;
|
|||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public class OpenZaakIntegrationTests
|
public class OpenZaakIntegrationTests
|
||||||
{
|
{
|
||||||
private static WebApplicationFactory<Program> Factory()
|
private static WebApplicationFactory<Program> Factory(string zaaktypeUrl)
|
||||||
{
|
{
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db");
|
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db");
|
||||||
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
||||||
@@ -34,23 +36,39 @@ public class OpenZaakIntegrationTests
|
|||||||
.UseSetting("Zgw:ClientId", "bigregister-test")
|
.UseSetting("Zgw:ClientId", "bigregister-test")
|
||||||
.UseSetting("Zgw:Secret", "bigregister-test-secret")
|
.UseSetting("Zgw:Secret", "bigregister-test-secret")
|
||||||
.UseSetting("Zgw:UserId", "bigregister-test")
|
.UseSetting("Zgw:UserId", "bigregister-test")
|
||||||
.UseSetting("Zgw:UserRepresentation", "WP-54 integration test"));
|
.UseSetting("Zgw:UserRepresentation", "WP-54 integration test")
|
||||||
|
.UseSetting("Zgw:ZaaktypeUrls:herregistratie", zaaktypeUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>bootstrap-catalogus.sh mints the seeded zaaktype's uuid fresh per harness
|
||||||
|
/// instance, so unlike every other setting <c>Factory</c> hardcodes, this one has to be
|
||||||
|
/// discovered live — the same real HTTP + JWT this test is meant to exercise, done once up
|
||||||
|
/// front to learn the URL <c>Zgw:ZaaktypeUrls</c> needs (see <see cref="OpenZaakZaakSource.AanvraagTypeFor"/>).</summary>
|
||||||
|
private static async Task<string> SeededZaaktypeUrlAsync()
|
||||||
|
{
|
||||||
|
var tokenOptions = new ZgwOptions { ClientId = "bigregister-test", Secret = "bigregister-test-secret" };
|
||||||
|
using var client = new HttpClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", new ZgwTokenProvider(tokenOptions).Mint());
|
||||||
|
client.DefaultRequestHeaders.Add("Accept-Crs", "EPSG:4326"); // else OpenZaak 412s
|
||||||
|
var page = await client.GetFromJsonAsync<ZgwPage<ZgwZaak>>(
|
||||||
|
"http://localhost:8000/zaken/api/v1/zaken?identificatie=BIG-2026-000123");
|
||||||
|
return Assert.Single(page!.Results).Zaaktype;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT()
|
public async Task Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT()
|
||||||
{
|
{
|
||||||
using var factory = Factory();
|
using var factory = Factory(await SeededZaaktypeUrlAsync());
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage)
|
client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage)
|
||||||
|
|
||||||
var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases");
|
var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases");
|
||||||
|
|
||||||
Assert.NotNull(cases);
|
Assert.NotNull(cases);
|
||||||
// bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123, under a
|
// bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123.
|
||||||
// zaaktype whose omschrijving is "Herregistratie arts" — see backend/openzaak/README.md.
|
|
||||||
var seeded = Assert.Single(cases!, c => c.Status.Referentie == "BIG-2026-000123");
|
var seeded = Assert.Single(cases!, c => c.Status.Referentie == "BIG-2026-000123");
|
||||||
Assert.Equal("Herregistratie arts", seeded.Type);
|
Assert.Equal("herregistratie", seeded.Type);
|
||||||
Assert.Equal("InBehandeling", seeded.Status.Tag);
|
Assert.Equal("InBehandeling", seeded.Status.Tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,58 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
using BigRegister.Api.Zgw;
|
using BigRegister.Api.Zgw;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
|
||||||
namespace BigRegister.Tests;
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no
|
/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no
|
||||||
/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches
|
/// mocking library) — the guarantee that it follows ZGW pagination, maps a zaak's zaaktype
|
||||||
/// zaaktype labels, and always sends a Bearer token.
|
/// back to the internal aanvraag-type key, and always sends a Bearer token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class OpenZaakZaakSourceTests
|
public class OpenZaakZaakSourceTests
|
||||||
{
|
{
|
||||||
private const string ZrcBase = "https://oz.example/zaken/api/v1";
|
private const string ZrcBase = "https://oz.example/zaken/api/v1";
|
||||||
private const string ZtBase = "https://oz.example/catalogi/api/v1";
|
private const string ZtBase = "https://oz.example/catalogi/api/v1";
|
||||||
|
private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1";
|
||||||
|
|
||||||
private static string Page1 => $$"""
|
private static string Page1 => $$"""
|
||||||
{ "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [
|
{ "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [
|
||||||
{ "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1",
|
{ "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1",
|
||||||
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01",
|
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-03-01",
|
||||||
"einddatum": null, "registratiedatum": "2026-03-01" } ] }
|
"einddatum": null, "registratiedatum": "2026-03-01" } ] }
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static string Page2 => $$"""
|
private static string Page2 => $$"""
|
||||||
{ "count": 2, "next": null, "results": [
|
{ "count": 2, "next": null, "results": [
|
||||||
{ "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2",
|
{ "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2",
|
||||||
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01",
|
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-01-01",
|
||||||
"einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] }
|
"einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] }
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }""";
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Follows_pagination_caches_zaaktype_and_sends_bearer_token()
|
public void Follows_pagination_maps_the_internal_aanvraag_type_and_sends_bearer_token()
|
||||||
{
|
{
|
||||||
|
// Regression for a real bug found via a live behandelportal walkthrough: this used to
|
||||||
|
// return OpenZaak's human zaaktype label ("Herregistratie arts") as Type, which the FE's
|
||||||
|
// AANVRAAG_TYPES trust boundary always rejects (it only accepts the internal key, the
|
||||||
|
// same contract LocalZaakSource honors) — every werkvoorraad load failed to parse.
|
||||||
var handler = new ZgwStubHandler(url => url switch
|
var handler = new ZgwStubHandler(url => url switch
|
||||||
{
|
{
|
||||||
_ when url == $"{ZrcBase}/zaken" => Page1,
|
_ when url == $"{ZrcBase}/zaken" => Page1,
|
||||||
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
|
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
|
||||||
_ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype,
|
|
||||||
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
|
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
|
||||||
});
|
});
|
||||||
|
|
||||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
var options = new ZgwOptions
|
||||||
|
{
|
||||||
|
ZrcBaseUrl = ZrcBase,
|
||||||
|
ZtcBaseUrl = ZtBase,
|
||||||
|
ClientId = "c",
|
||||||
|
Secret = "s",
|
||||||
|
ZaaktypeUrls = new() { ["herregistratie"] = ZaaktypeUrl },
|
||||||
|
};
|
||||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||||
|
|
||||||
var cases = source.ListCases(DateTimeOffset.UtcNow);
|
var cases = source.ListCases(DateTimeOffset.UtcNow);
|
||||||
@@ -50,16 +60,31 @@ public class OpenZaakZaakSourceTests
|
|||||||
// Both pages accumulated.
|
// Both pages accumulated.
|
||||||
Assert.Equal(2, cases.Count);
|
Assert.Equal(2, cases.Count);
|
||||||
Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id));
|
Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id));
|
||||||
Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type));
|
Assert.All(cases, c => Assert.Equal("herregistratie", c.Type));
|
||||||
Assert.Equal("InBehandeling", cases[0].Status.Tag); // open
|
Assert.Equal("InBehandeling", cases[0].Status.Tag); // open
|
||||||
Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed
|
Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed
|
||||||
|
|
||||||
// Zaaktype resolved once despite two zaken sharing it (cache).
|
// No Catalogi round-trip needed — the type maps back via the local Zgw:ZaaktypeUrls config.
|
||||||
Assert.Single(handler.Requests, r => r.Contains("zaaktypen"));
|
Assert.DoesNotContain(handler.Requests, r => r.Contains("zaaktypen"));
|
||||||
// Every outbound request carried a Bearer token.
|
// Every outbound request carried a Bearer token.
|
||||||
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
|
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListCases_throws_when_a_zaak_zaaktype_has_no_configured_aanvraag_type()
|
||||||
|
{
|
||||||
|
var handler = new ZgwStubHandler(url => url switch
|
||||||
|
{
|
||||||
|
_ when url == $"{ZrcBase}/zaken" => Page1,
|
||||||
|
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
|
||||||
|
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
|
||||||
|
});
|
||||||
|
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||||
|
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => source.ListCases(DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListMyCases_filters_by_the_callers_bsn()
|
public void ListMyCases_filters_by_the_callers_bsn()
|
||||||
{
|
{
|
||||||
@@ -174,6 +199,11 @@ public class OpenZaakZaakSourceTests
|
|||||||
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
|
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
|
||||||
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
|
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
|
||||||
""",
|
""",
|
||||||
|
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
|
||||||
|
{ "count": 1, "next": null,
|
||||||
|
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
|
||||||
|
""",
|
||||||
|
_ when url == $"{ZrcBase}/resultaten" => "{}",
|
||||||
_ when url == $"{ZrcBase}/statussen" => "{}",
|
_ when url == $"{ZrcBase}/statussen" => "{}",
|
||||||
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
||||||
});
|
});
|
||||||
@@ -207,6 +237,59 @@ public class OpenZaakZaakSourceTests
|
|||||||
Assert.Contains("onvolledig", statusBody);
|
Assert.Contains("onvolledig", statusBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordBesluit_creates_a_resultaat_before_posting_the_eindstatus()
|
||||||
|
{
|
||||||
|
// Regression for a real bug found against a live OpenZaak: posting straight to the eind
|
||||||
|
// statustype without a Resultaat first gets rejected with 400 "Zaak has no resultaat" —
|
||||||
|
// ZGW requires the Resultaat to exist before a zaak can reach its eindstatus.
|
||||||
|
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
|
||||||
|
var handler = new ZgwStubHandler(url => url switch
|
||||||
|
{
|
||||||
|
_ when url.StartsWith($"{ZtBase}/statustypen") => """
|
||||||
|
{ "count": 1, "next": null,
|
||||||
|
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
|
||||||
|
""",
|
||||||
|
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
|
||||||
|
{ "count": 1, "next": null,
|
||||||
|
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
|
||||||
|
""",
|
||||||
|
_ when url == $"{ZrcBase}/resultaten" => "{}",
|
||||||
|
_ when url == $"{ZrcBase}/statussen" => "{}",
|
||||||
|
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = new ZgwOptions
|
||||||
|
{
|
||||||
|
ZrcBaseUrl = ZrcBase,
|
||||||
|
ZtcBaseUrl = ZtBase,
|
||||||
|
ClientId = "c",
|
||||||
|
Secret = "s",
|
||||||
|
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
|
||||||
|
};
|
||||||
|
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||||
|
var aanvraag = new Aanvraag
|
||||||
|
{
|
||||||
|
Id = "a1",
|
||||||
|
Type = "registratie",
|
||||||
|
Owner = "111222333",
|
||||||
|
Referentie = "BIG-2026-000123",
|
||||||
|
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
|
||||||
|
};
|
||||||
|
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||||
|
|
||||||
|
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
|
||||||
|
|
||||||
|
var resultaatBody = handler.BodyOf($"{ZrcBase}/resultaten");
|
||||||
|
Assert.Contains($"{ZrcBase}/zaken/uuid-existing", resultaatBody);
|
||||||
|
Assert.Contains("resultaattypen/rst-1", resultaatBody);
|
||||||
|
|
||||||
|
// The Resultaat must exist BEFORE the eindstatus is posted, not after.
|
||||||
|
Assert.True(
|
||||||
|
handler.Requests.IndexOf($"{ZrcBase}/resultaten") < handler.Requests.IndexOf($"{ZrcBase}/statussen"),
|
||||||
|
"expected /resultaten to be posted before /statussen");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
|
public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Applications;
|
||||||
using BigRegister.Domain.Beoordeling;
|
using BigRegister.Domain.Beoordeling;
|
||||||
using BigRegister.Domain.Diplomas;
|
using BigRegister.Domain.Diplomas;
|
||||||
using BigRegister.Domain.Documents;
|
using BigRegister.Domain.Documents;
|
||||||
@@ -192,4 +193,49 @@ public class BeoordelingRuleTests
|
|||||||
[InlineData(AanvraagStatusTag.Afgewezen, false)]
|
[InlineData(AanvraagStatusTag.Afgewezen, false)]
|
||||||
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
|
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
|
||||||
Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
|
Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
|
||||||
|
|
||||||
|
// WP-68 (F6): the toelichting rule, moved here from an inline endpoint check.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Besluit.Goedkeuren, false)]
|
||||||
|
[InlineData(Besluit.Afwijzen, true)]
|
||||||
|
[InlineData(Besluit.MeerInfoOpvragen, true)]
|
||||||
|
public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) =>
|
||||||
|
Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit));
|
||||||
|
|
||||||
|
// WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag —
|
||||||
|
// an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal
|
||||||
|
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
|
||||||
|
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
|
||||||
|
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
|
||||||
|
private static Aanvraag Decided(Besluit besluit) => new()
|
||||||
|
{
|
||||||
|
Id = "x",
|
||||||
|
Type = "registratie",
|
||||||
|
Owner = "test",
|
||||||
|
Submitted = true,
|
||||||
|
Referentie = "BIG-2026-1",
|
||||||
|
SubmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
BesluitStatus = besluit,
|
||||||
|
BesluitToelichting = besluit == Besluit.Goedkeuren ? null : "toelichting",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Besluit.Goedkeuren)]
|
||||||
|
[InlineData(Besluit.Afwijzen)]
|
||||||
|
public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var tag = Decided(recorded).StatusAt(now).Tag!.Value;
|
||||||
|
Assert.False(BeoordelingRules.CanDecide(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var tag = Decided(Besluit.MeerInfoOpvragen).StatusAt(now).Tag!.Value;
|
||||||
|
Assert.True(BeoordelingRules.CanDecide(tag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,75 +49,77 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
|
|||||||
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
||||||
for its existing violations, so every WP ends green.
|
for its existing violations, so every WP ends green.
|
||||||
|
|
||||||
| WP | Title | Phase | Status |
|
| WP | Title | Phase | Status |
|
||||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
||||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
||||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
||||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
|
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
|
||||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
||||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
||||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
||||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
||||||
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||||
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||||
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
|
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
|
||||||
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
||||||
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
|
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
|
||||||
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
|
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
|
||||||
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
|
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
|
||||||
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done |
|
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done |
|
||||||
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
|
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
|
||||||
| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done |
|
| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done |
|
||||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
||||||
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
||||||
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
||||||
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
|
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
|
||||||
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
||||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
||||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
|
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
|
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
|
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
|
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
|
||||||
| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
|
| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
|
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
|
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
|
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
|
||||||
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
|
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
|
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
|
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
|
||||||
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
|
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
|
||||||
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
|
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
|
||||||
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
|
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
|
||||||
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
|
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
|
||||||
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
|
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
|
||||||
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
|
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
|
||||||
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done |
|
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done |
|
||||||
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done |
|
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done |
|
||||||
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
|
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
|
||||||
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
|
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
|
||||||
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
||||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
||||||
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
||||||
|
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | done |
|
||||||
|
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | todo |
|
||||||
|
|
||||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
|
||||||
|
|
||||||
|
Status: done (a394950..472a49f)
|
||||||
|
Phase: 12 — DDD hardening
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
An architecture review on 2026-08-05 (bounded contexts, aggregates, CQRS, DDD/BDD test
|
||||||
|
alignment, measured against this repo's own documented pattern) found the context boundaries,
|
||||||
|
the FP/TEA idioms and the read/write separation to be sound — and found four defects clustered
|
||||||
|
in one place: **the backend's aggregate roots do not guard their own invariants, and the
|
||||||
|
aanvraag status lifecycle is a computed string living in the contracts layer.**
|
||||||
|
|
||||||
|
The four in this WP, in dependency order:
|
||||||
|
|
||||||
|
- **F1 — `submit` links client-supplied `documentId`s with no ownership check.**
|
||||||
|
`Program.cs:353-356` takes document ids straight from the request body and hands them to
|
||||||
|
`ApplicationStore.Submit` and `documents.LinkToZaak`; `DocumentStore.Link` has no `owner`
|
||||||
|
parameter and performs no check (`DocumentStore.cs:113-125`). Same for `SyncDraft`
|
||||||
|
(`Program.cs:317`). A caller who knows a foreign document GUID can attach another citizen's
|
||||||
|
upload to their own aanvraag — where it appears on the behandelaar's beoordeling screen with
|
||||||
|
its filename (`Program.cs:434`) and is POSTed to OpenZaak as a zaakinformatieobject on
|
||||||
|
_their_ zaak — and flips the victim's `Linked = true`, which permanently blocks the victim's
|
||||||
|
own delete (`DeleteOwned` → `DeleteResult.Linked`). ADR-0001 is explicit that the FE holds
|
||||||
|
no authority; this trusts it anyway.
|
||||||
|
|
||||||
|
- **F3 — the aanvraag status lifecycle is a computed string in `Contracts/`.** Three
|
||||||
|
compounding facts: the status is derived in `Contracts/Mappers.ToStatusDto`
|
||||||
|
(`Mappers.cs:44-63`), not in the domain; `Concept` is **not** a member of
|
||||||
|
`AanvraagStatusTag` (`ApplicationStore.cs:14`) but a magic string the mapper emits; and the
|
||||||
|
write path reads its own guard back out of the read DTO —
|
||||||
|
`a.ToStatusDto(now).Tag` → compare `"Concept"` → `Enum.Parse<AanvraagStatusTag>`
|
||||||
|
(`Program.cs:466-468`). This violates the repo's non-negotiable #3 ("make illegal states
|
||||||
|
unrepresentable") on the backend's most important type: the status is
|
||||||
|
`enum + one string that is not in the enum`, so `Enum.Parse` is a runtime throw waiting for
|
||||||
|
a new tag. It is also the one genuine CQRS symptom in the codebase — a command deriving its
|
||||||
|
invariant from a read projection — and it is _why_ F2 exists: there is no domain object that
|
||||||
|
could have owned the guard.
|
||||||
|
|
||||||
|
- **F2 — the besluit invariant is checked outside the write transaction.**
|
||||||
|
`Program.cs:469` calls `BeoordelingRules.CanDecide`; the write happens later in
|
||||||
|
`ApplicationStore.RecordBesluit` (`ApplicationStore.cs:278-291`), which takes the lock and
|
||||||
|
assigns unconditionally. Two concurrent besluiten both pass the check and both write, so the
|
||||||
|
second silently overwrites a terminal decision the rule exists to freeze. The codebase
|
||||||
|
already documents the correct pattern three methods earlier — `CreateConcept`: _"Race-free:
|
||||||
|
the existence check and the insert share the single write gate."_ This is an internal
|
||||||
|
inconsistency, not a missing concept.
|
||||||
|
|
||||||
|
- **F6 — a besluit rule with no home in `Domain/`.** "Toelichting verplicht bij Afwijzen /
|
||||||
|
MeerInfoOpvragen" lives inline at `Program.cs:473`, although `BeoordelingRules`' own
|
||||||
|
doc-comment says the decision-recording rules were meant to land there. It therefore has no
|
||||||
|
unit test, only the endpoint test `Afwijzen_requires_a_toelichting`.
|
||||||
|
|
||||||
|
Plus one documentation correction (**F5**, see Decisions — the enforcement itself is deferred
|
||||||
|
to WP-69, because it needs a wire change).
|
||||||
|
|
||||||
|
The review's remaining findings are listed under "Follow-ups" and are **not** this WP's scope.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `CLAUDE.md` §"The decisions" #3 (make illegal states unrepresentable) and #4 (BFF-lite)
|
||||||
|
- [ADR-0001 — BFF-lite + decision DTOs](../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||||
|
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (the `Aanvraag` entity, the store's
|
||||||
|
lock discipline, `AanvraagStatusTag`, `RecordBesluit`)
|
||||||
|
- `backend/src/BigRegister.Api/Contracts/Mappers.cs` (`ToStatusDto` — the logic to move)
|
||||||
|
- `backend/src/BigRegister.Api/Program.cs` lines 300-500 (draft sync, submit, beoordeling GET,
|
||||||
|
besluit POST)
|
||||||
|
- `backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs` (**the second producer of the status
|
||||||
|
DTO** — easy to miss)
|
||||||
|
- `backend/src/BigRegister.Api/Data/DocumentStore.cs` (`Link`, `DeleteOwned`, the existing
|
||||||
|
`DeleteResult` enum this WP copies)
|
||||||
|
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
**Commit or stash the working tree first.** At review time it carried the WP-66 id-mismatch fix
|
||||||
|
across 11 modified files plus the untracked `backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs`.
|
||||||
|
Do not start a cross-cutting refactor on top of uncommitted work.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Pre-made — do not relitigate.
|
||||||
|
|
||||||
|
### F3 — the status type
|
||||||
|
|
||||||
|
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
|
||||||
|
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`).
|
||||||
|
**`ApplicationStore.ProcessingWindow` stays where it is.** The original text here said to
|
||||||
|
move it too "because `StatusAt` needs it" — but `StatusAt` is an instance method on
|
||||||
|
`Aanvraag`, itself defined in `ApplicationStore.cs`, so it already sits in the same file/
|
||||||
|
namespace as `ProcessingWindow` and can reference it directly with no cross-namespace
|
||||||
|
issue. Moving it would have been motion without a reason, and — found only once
|
||||||
|
implementation started — `ApplicationTests.cs` references `ApplicationStore.ProcessingWindow`
|
||||||
|
directly in two tests this WP's own acceptance criteria require to stay **unmodified**;
|
||||||
|
moving the constant would have forced a choice between breaking that criterion or adding a
|
||||||
|
forwarding shim for no gain. Leave it.
|
||||||
|
2. **`AanvraagStatusTag` is NOT given a `Concept` member — implemented differently, deliberately.**
|
||||||
|
The original text said to add `Concept` as the first member. That directly conflicts with
|
||||||
|
this WP's own acceptance criterion that `AanvraagStatusTag_covers_the_published_lifecycle`
|
||||||
|
(which asserts `Enum.GetNames<AanvraagStatusTag>()` equals exactly the five published-lifecycle
|
||||||
|
names) passes **unmodified** — adding a sixth name breaks it. Found only once implementation
|
||||||
|
started; resolved in favor of the harder constraint (the regression-net test) and a cleaner
|
||||||
|
design: **`AanvraagStatus.Tag` is `AanvraagStatusTag?`, null exactly for Concept.** This
|
||||||
|
still closes the actual finding (a magic string with no corresponding enum member,
|
||||||
|
round-tripped through the DTO and `Enum.Parse`d) without touching the enum the test pins,
|
||||||
|
and without the reduce-only "boolean + tag" shape rule #3 warns against — a nullable
|
||||||
|
discriminator is the standard two-case union, not a second boolean bolted on. `Ingediend`
|
||||||
|
is unaffected by this and is still kept reserved (see below).
|
||||||
|
Keep `Ingediend` even though nothing produces it today (verified: neither `ToStatusDto` nor
|
||||||
|
`ZgwZaakMapper` emits it) — `BeoordelingRules.CanDecide` accepts it, the FE's
|
||||||
|
`BeoordelingStatus` union declares it, `statusLabel` has a `$localize` id for it, and
|
||||||
|
`Only_open_statuses_are_decidable` tests it. Deleting it would ripple into
|
||||||
|
`messages.en.xlf`. Mark it reserved with a comment instead.
|
||||||
|
3. **New `Domain/Applications/AanvraagStatus.cs`**: a `sealed class` (not a `record` — no
|
||||||
|
external mutation via `with` is wanted, and record value-equality/`ToString` boilerplate
|
||||||
|
buys nothing for a short-lived read model) carrying `AanvraagStatusTag? Tag` (null =
|
||||||
|
Concept) plus the same optional payload fields the DTO has (`StepIndex`, `StepCount`,
|
||||||
|
`Referentie`, `Manual`, `Reden`), constructed **only** via static factories —
|
||||||
|
`Concept(stepIndex, stepCount)`, `InBehandeling(referentie, manual)`,
|
||||||
|
`Goedgekeurd(referentie)`, `Afgewezen(referentie, reden)`,
|
||||||
|
`MeerInfoGevraagd(referentie, reden)`.
|
||||||
|
**Rejected: a full abstract-record union** (one subrecord per tag). It is the purer
|
||||||
|
modelling, but it forces exhaustive switches at four call sites and a per-case mapper for a
|
||||||
|
marginal gain over "the factories are the only construction path". Not worth the diff here.
|
||||||
|
4. **`Aanvraag.StatusAt(DateTimeOffset now)`** — an instance method on the entity carrying the
|
||||||
|
logic currently in `ToStatusDto` **verbatim**, including the "a recorded decision wins over
|
||||||
|
the auto-approve computation" ordering.
|
||||||
|
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`, via a shared
|
||||||
|
`Mappers.ToDto(this AanvraagStatus s)` extension (also used by `ZgwZaakMapper` — see below,
|
||||||
|
point 7 — so both status producers agree on one projection):
|
||||||
|
`new(s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
|
||||||
|
6. **`AanvraagStatusDto` is unchanged — `Tag` stays a `string`.** This is the safety property
|
||||||
|
that makes F3 an internal refactor: **no wire change, no `gen:api` drift, no frontend
|
||||||
|
change, no `messages.en.xlf` change.** Do not "improve" the DTO in this WP.
|
||||||
|
7. **`ZgwZaakMapper` is the second producer** and must be converted too, or the string literals
|
||||||
|
survive: `ToSummaryDto` and `ToCreatedStatusDto` build `AanvraagStatus` via the factories and
|
||||||
|
project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →
|
||||||
|
`InBehandeling` with `Manual: true`; closed → `Goedgekeurd`) — `ZgwZaakMapperTests` is the net.
|
||||||
|
8. **The besluit endpoint stops going through the DTO**: `var status = a.StatusAt(now);`
|
||||||
|
compare `status.Tag == AanvraagStatusTag.Concept`, pass `status.Tag` to `CanDecide`. The
|
||||||
|
`Enum.Parse` at `Program.cs:468` is deleted.
|
||||||
|
9. **One `Enum.Parse` may remain** — the beoordeling GET at `Program.cs:438`, which parses a tag
|
||||||
|
off a DTO returned by the `IZaakSource` seam. That is a genuine wire→domain trust boundary,
|
||||||
|
not a smell. Keep exactly one, make it non-throwing for an unknown tag, and comment it as the
|
||||||
|
seam boundary. **Changing `IZaakSource` to return domain types is out of scope.**
|
||||||
|
|
||||||
|
### F2 — the besluit guard
|
||||||
|
|
||||||
|
`ApplicationStore.RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)`
|
||||||
|
returns `(RecordBesluitOutcome Outcome, Aanvraag? Aanvraag)` with
|
||||||
|
`enum RecordBesluitOutcome { Ok, NotFound, Conflict }` — mirroring the existing
|
||||||
|
`DocumentStore.DeleteResult` precedent rather than inventing a new result idiom. Inside the
|
||||||
|
lock: find, `StatusAt(now)`, `CanDecide` → `Conflict` if refused, then write. **The endpoint
|
||||||
|
drops its own pre-check** and maps the outcome to 200/404/409, so there is one source of truth
|
||||||
|
for the transition. The endpoint keeps its id-resolution and its `Concept` → 404 (both need the
|
||||||
|
`IZaakSource` lookup the store cannot see).
|
||||||
|
|
||||||
|
### F1 — document ownership
|
||||||
|
|
||||||
|
New `DocumentStore.ForeignIds(IEnumerable<string> ids, string owner)` returning the ids that do
|
||||||
|
**not** resolve to a document owned by `owner` (returning the offending ids, not a bool, so the
|
||||||
|
ProblemDetails can name them). Called in `POST /applications/{id}/submit` **before** any write,
|
||||||
|
and in the draft-sync endpoint (`Program.cs:317`); non-empty → 400 ProblemDetails.
|
||||||
|
|
||||||
|
Endpoint-level check only. `IDocumentSource.LinkToZaak` keeps its current signature (two
|
||||||
|
implementations, and the endpoint has now validated its input) — add a comment saying so.
|
||||||
|
"A document already linked to a different aanvraag of the same owner" is **not** covered here;
|
||||||
|
note it as a follow-up, do not build it.
|
||||||
|
|
||||||
|
### F5 — narrowed to a doc fix
|
||||||
|
|
||||||
|
`IntakePolicy`'s XML doc-comment claims _"the backend re-validates on submit as the
|
||||||
|
authority"_. It does not: the constant's only consumer is `Program.cs:155`, which echoes it, and
|
||||||
|
both submit paths apply `SubmissionRules.RejectZeroUren` only. Verified cause: **neither
|
||||||
|
`SubmitApplicationRequest(DiplomaHerkomst, Uren, Documents)` nor `IntakeRequest(int Uren)`
|
||||||
|
carries a scholing answer at all**, so the server cannot re-validate without a contract change,
|
||||||
|
and the wizard's answers (`scholingGevolgd`, `punten` — `intake.machine.ts:26,37`) never reach
|
||||||
|
it. Reading them out of the opaque `Draft` JSON is rejected: the backend's documented posture is
|
||||||
|
that the draft is opaque (`AppDbContext` header comment).
|
||||||
|
|
||||||
|
**In this WP: correct the doc-comment to state the gap, and nothing else.** The enforcement is
|
||||||
|
WP-69 (a real FE+BE slice: request fields, `IntakePolicy.RejectMissingScholing`, wizard payload,
|
||||||
|
`gen:api`).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `Domain/Applications/AanvraagStatus.cs` (new — tag enum, `Besluit`, `ProcessingWindow`, the
|
||||||
|
status record + factories)
|
||||||
|
- `Data/ApplicationStore.cs` (`Aanvraag.StatusAt`, `RecordBesluit` signature + in-lock guard,
|
||||||
|
enums moved out)
|
||||||
|
- `Contracts/Mappers.cs` (`ToStatusDto` reduced to a projection)
|
||||||
|
- `Zgw/ZgwZaakMapper.cs` (both producers converted)
|
||||||
|
- `Data/DocumentStore.cs` (`ForeignIds`)
|
||||||
|
- `Domain/Beoordeling/BeoordelingRules.cs` (`RequiresToelichting`)
|
||||||
|
- `Domain/Intake/IntakePolicy.cs` (doc-comment only)
|
||||||
|
- `Program.cs` (submit + draft-sync ownership checks; besluit endpoint simplified)
|
||||||
|
- `tests/BigRegister.Tests/` — `RuleTests.cs` (new `AanvraagStatusTests` nested class +
|
||||||
|
`RequiresToelichting`), `ApplicationTests.cs` (ownership), `BeoordelingTests.cs` (concurrency)
|
||||||
|
|
||||||
|
No migration: no persisted column changes (`BesluitStatus` already stores `Besluit`, whose
|
||||||
|
member names are unchanged).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Commit/stash the WP-66 working tree (see Prerequisite).
|
||||||
|
2. **F1** — `DocumentStore.ForeignIds` + the two endpoint checks + tests. Independent of the
|
||||||
|
rest; land it first so the correctness fix is not blocked by the refactor.
|
||||||
|
3. **F3** — the status type, in Decisions order 1→9. `dotnet test` green with
|
||||||
|
`AanvraagStatusTag_covers_the_published_lifecycle`,
|
||||||
|
`AutoApprovable_flips_to_goedgekeurd_after_the_window` and `ZgwZaakMapperTests` **unchanged**
|
||||||
|
— those three are the regression net for the refactor.
|
||||||
|
4. **F2** — `RecordBesluitOutcome`, guard moved inside the lock, endpoint maps the outcome.
|
||||||
|
5. **F6** — `BeoordelingRules.RequiresToelichting` + unit test; endpoint calls it.
|
||||||
|
6. **T3** — the lifecycle spec that F3 makes expressible: one `[Theory]` over
|
||||||
|
(status × besluit) → allowed/denied, asserting among others that Afgewezen → Goedgekeurd is
|
||||||
|
refused as a _domain_ statement, not only at the endpoint.
|
||||||
|
7. **F5** — correct the `IntakePolicy` doc-comment; open WP-69 for the enforcement.
|
||||||
|
8. Run the full gate (see Verification).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Submitting (or draft-syncing) an aanvraag with a `documentId` owned by another citizen is
|
||||||
|
rejected with 400, and the other citizen's document remains deletable
|
||||||
|
(`DeleteResult.Ok`). (`Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner`,
|
||||||
|
`Draft_sync_with_a_foreign_documentId_is_rejected`.)
|
||||||
|
- [x] `AanvraagStatusTag` does NOT contain `Concept` — implemented instead as
|
||||||
|
`AanvraagStatus.Tag` being `AanvraagStatusTag?`, null exactly for Concept (see Decisions
|
||||||
|
§F3.2 for why this replaced the original "add Concept to the enum" instruction). No
|
||||||
|
_internal domain_ code compares a status against the `"Concept"` string; the one
|
||||||
|
remaining comparison (`Program.cs`'s beoordeling GET, against `IZaakSource`'s wire DTO)
|
||||||
|
is the deliberate wire-boundary exception, paired with the one allowed `Enum.TryParse`
|
||||||
|
below.
|
||||||
|
- [x] `Enum.Parse`/`TryParse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at
|
||||||
|
the `IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown
|
||||||
|
tag (`Enum.TryParse` there, not `Enum.Parse`).
|
||||||
|
- [x] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
|
||||||
|
- [x] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
|
||||||
|
- [x] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
|
||||||
|
`libs/shared/src/infrastructure/api-client.ts` beyond F1's new 400 responses (verified —
|
||||||
|
the only diff after F3 is the two `.ProducesProblem(400)` blocks F1 added; proof F3
|
||||||
|
changed no wire shape).
|
||||||
|
- [x] Two concurrent `POST /beoordeling/{id}/besluit` racing on the same still-open aanvraag
|
||||||
|
yield exactly one 200 and one 409; the persisted status matches whichever request won
|
||||||
|
(`Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success`, stable across 5
|
||||||
|
repeated runs).
|
||||||
|
- [x] `BeoordelingRules.RequiresToelichting` exists, is unit-tested
|
||||||
|
(`Only_a_non_approval_requires_a_toelichting`), and is the only place the rule lives.
|
||||||
|
- [x] A `[Theory]`/aggregate-level test covers the transition table
|
||||||
|
(`A_terminal_decision_refuses_any_further_besluit`,
|
||||||
|
`MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal` — via
|
||||||
|
`Aanvraag.StatusAt` + `BeoordelingRules.CanDecide`, not just a bare-tag `[Theory]`, since
|
||||||
|
`CanDecide` doesn't vary by which besluit is attempted — see Decisions for why a literal
|
||||||
|
status×besluit cross-product theory would have been redundant with
|
||||||
|
`Only_open_statuses_are_decidable`).
|
||||||
|
- [x] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists
|
||||||
|
(`docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md`).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && dotnet test # while iterating
|
||||||
|
npm run gen:api && git diff --exit-code backend/swagger.json libs/shared/src/infrastructure/api-client.ts
|
||||||
|
npm run ci # the full gate before pushing
|
||||||
|
npm run e2e # after F1/F2/F3 — needs the backend + `npm start` running
|
||||||
|
```
|
||||||
|
|
||||||
|
The three existing tests named in step 3 must pass **unmodified**; if a refactor step needs one
|
||||||
|
of them changed, the refactor changed behaviour and is wrong.
|
||||||
|
|
||||||
|
**Result:** `npm run ci` passed fully green — lint, format:check, check:tokens, all four test
|
||||||
|
suites, both localized builds, `npm audit`, backend `dotnet format`+`dotnet test` (216 passing,
|
||||||
|
up from 207 at the start of this WP), snippet-generator drift, and API-client drift (only F1's
|
||||||
|
new 400 responses; F3 shows zero additional wire diff, per acceptance criteria). `npm run e2e`
|
||||||
|
could **not** be verified in this session: port 4200 was already occupied by an unrelated
|
||||||
|
container (`team-monitor-web-1`, a different repo) that Playwright's local `reuseExistingServer`
|
||||||
|
reused as if it were this app, so every test timed out waiting for a `BSN` field that container
|
||||||
|
doesn't have — a pre-existing local port collision, not a regression (nothing in this WP touches
|
||||||
|
ports/docker), and per CLAUDE.md's GREEN definition `npm run e2e` isn't part of the local GREEN
|
||||||
|
gate regardless. Free port 4200 (or set `E2E_BASE_URL`) and re-run `npm run e2e` to close this
|
||||||
|
out if end-to-end confirmation is wanted.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Deliberately excluded — each is a separate WP if wanted:
|
||||||
|
|
||||||
|
- **F4** — backend layer enforcement. `Domain/Beoordeling/BeoordelingRules.cs` and
|
||||||
|
`Domain/Authorization/Authz.cs` import `BigRegister.Api.Data` (and `Authz` also
|
||||||
|
`.Contracts`, returning `BriefDecisionsDto`), with nothing in CI checking direction — the FE
|
||||||
|
has `dep:check`, the backend has only `dotnet format` + `dotnet test`. This WP's step 3
|
||||||
|
removes the `BeoordelingRules` violation as a side effect; the `Authz` one and the ~6-line
|
||||||
|
reflection convention test are WP-70.
|
||||||
|
- **F5 enforcement** → WP-69 (see Decisions).
|
||||||
|
- **F7** — `ApplicationStore.Submit` and `DocumentStore.Link` take separate locks with no
|
||||||
|
transaction and no compensation; a link failure leaves a submitted aanvraag whose documents
|
||||||
|
are still deletable. Same failure class WP-60 closed for ZGW and left open locally. Fix is to
|
||||||
|
route it through the existing divergence flag + audit row, not to merge the aggregates.
|
||||||
|
- **F8** — pushing invariants from the static stores onto `Aanvraag` as instance methods
|
||||||
|
(`TryRecordBesluit`). This WP does the two that matter; the general move can wait.
|
||||||
|
- **F9** — `Authz` spans five contexts and its four admin gates are byte-identical
|
||||||
|
`role == Admin` checks with **no direct unit test** and no test denying `Approver`.
|
||||||
|
- **F10** — splitting `Program.cs` (917 lines, 50 endpoints). **Deliberately deferred and
|
||||||
|
flagged as risky:** `OrgAdmin`, `StamdataAdmin`, `Beoordelen`, `Submit` and `AuditAuthz` are
|
||||||
|
non-static **local functions** (`Program.cs:756+`) that every endpoint lambda closes over, so
|
||||||
|
splitting means converting all of them to static helpers with explicit dependencies across
|
||||||
|
all 50 registrations — with the deliberate authz ordering (Forbidden before Conflict) as the
|
||||||
|
thing that breaks silently. Lowest value of the review's findings; do it alone, with tests as
|
||||||
|
the net, or not at all.
|
||||||
|
- **F11** — three FE adapter fetch idioms; two loaders `throw` instead of returning `Result`;
|
||||||
|
`runSubmit` (which mints an `Idempotency-Key`) is used for **reads** in `brief.adapter.ts:56`,
|
||||||
|
`org-template.adapter.ts:39,51`, `stamdata.adapter.ts:27,42`. Fix is `runQuery`/`runCommand`
|
||||||
|
over one shared try/catch, ~10 lines.
|
||||||
|
- **T2** — ~54 FE `it()` titles are named after `Msg` tags (`'SetField updates the draft'`,
|
||||||
|
`'SubmitConfirmed maps Submitting to Submitted'`), against `bdd.mdx` rule 3. Titles only.
|
||||||
|
- **T5** — named coverage gaps: `OrgTemplateRules.RejectDraft` (both identity branches, no
|
||||||
|
margin boundary test), the four `Authz` admin gates, `DocumentRules.CategoriesFor`'s
|
||||||
|
`herregistratie`/`org-template` branches, `SubmissionRules.NewReference`, FE
|
||||||
|
`isStatusConsistent` (tested on the backend, never on the FE), the FE herregistratie window
|
||||||
|
boundary, and the FE/BE margin constants which mirror each other with no contract test.
|
||||||
|
- **T6** — trust-boundary `describe` naming has three dialects; 7 `parse*` specs use none.
|
||||||
|
- **ADR-0006 "CQS without CQRS"** — the review's learning deliverable: the read/write
|
||||||
|
separation already present, why the emit-and-enforce rule (one function feeding both the
|
||||||
|
decision flag and the enforcement) makes a read/write stack split actively harmful here, and
|
||||||
|
WP-60's deferred outbox as the documented trigger that would change the answer. Prose only,
|
||||||
|
no runtime code.
|
||||||
|
- Anything CQRS-mechanical: MediatR, handler classes, a separate read store, event sourcing,
|
||||||
|
repositories/unit-of-work, Gherkin/Reqnroll. All explicitly rejected by the review.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Scope creep on F3.** The temptation is to "fix" `AanvraagStatusDto` into a proper wire union
|
||||||
|
while in there. That turns a zero-diff internal refactor into an FE + `messages.en.xlf` +
|
||||||
|
`gen:api` change. The acceptance criterion "`gen:api` leaves no diff" exists to catch it.
|
||||||
|
- **Missing the second producer.** `ZgwZaakMapper` is easy to overlook because it lives under
|
||||||
|
`Zgw/`, not `Contracts/`. If it is missed, the string literals survive and the finding is only
|
||||||
|
half fixed.
|
||||||
|
- **Over-modelling.** A full abstract-record status union, or a repository/unit-of-work layer to
|
||||||
|
"properly" own the aggregate, would be a bigger diff than the defects justify — see Decisions.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# WP-69 — Enforce the scholing threshold server-side
|
||||||
|
|
||||||
|
Status: todo
|
||||||
|
Phase: 12 — DDD hardening
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
WP-68 (F5) found that `IntakePolicy`'s doc-comment claimed _"the backend re-validates on
|
||||||
|
submit as the authority"_ — it doesn't. `GET /intake/policy` only echoes `ScholingThreshold`;
|
||||||
|
neither `SubmitApplicationRequest` (`DiplomaHerkomst`, `Uren`, `Documents`) nor `IntakeRequest`
|
||||||
|
(`Uren`) carries a scholing answer at all, so there's nothing for the server to re-validate.
|
||||||
|
Both submit paths apply only `SubmissionRules.RejectZeroUren`. A crafted POST — bypassing the
|
||||||
|
wizard entirely — can skip the scholing requirement (`scholingGevolgd`/`punten` in
|
||||||
|
`intake.machine.ts`) even though it's presented as mandatory in the UI. ADR-0001's canonical
|
||||||
|
"config value" example (the FE applies the threshold for instant feedback, the backend
|
||||||
|
re-validates as authority) is unenforced for the one rule it was written to illustrate.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs` (the corrected doc-comment,
|
||||||
|
WP-68)
|
||||||
|
- [ADR-0001 — BFF-lite + decision DTOs](../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||||
|
§"config value"
|
||||||
|
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (`lageUren`, `scholingGevolgd`,
|
||||||
|
`punten` — the wizard's existing FE-side rule and its answers)
|
||||||
|
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` (`SubmitApplicationRequest`,
|
||||||
|
`IntakeRequest`, `DocumentRefDto`)
|
||||||
|
- `backend/src/BigRegister.Api/Program.cs` — the `intakes` and `applications/{id}/submit`
|
||||||
|
endpoints
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Not yet made — this is a placeholder WP opened by WP-68, not a ready-to-implement one. Needs
|
||||||
|
a `planner` pass before work starts. Open questions to resolve then:
|
||||||
|
|
||||||
|
- The request DTOs need a scholing answer field (likely mirroring `intake.machine.ts`'s
|
||||||
|
`ValidIntake.aanvullendeScholing`/`punten`) — this is a wire change, so it touches
|
||||||
|
`contracts/`, the wizard's submit payload, and `npm run gen:api`.
|
||||||
|
- Whether to add the rule to `SubmissionRules` (alongside `RejectZeroUren`) or give
|
||||||
|
`IntakePolicy` its own `RejectMissingScholing(uren, scholing)`, matching the class that
|
||||||
|
already owns the threshold.
|
||||||
|
- Reading the answer out of the wizard's `Draft` JSON was rejected in WP-68 — the backend's
|
||||||
|
documented posture is that the draft is opaque (`AppDbContext`'s header comment) — so the
|
||||||
|
answer must arrive as an explicit request field, not be extracted from the opaque snapshot.
|
||||||
|
|
||||||
|
## Out of scope (for now)
|
||||||
|
|
||||||
|
Implementation — this WP exists to track the gap; do not implement without a Decisions block.
|
||||||
@@ -175,10 +175,13 @@ JWT's audit claims reflect the behandelaar, not a static identity.
|
|||||||
ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests
|
ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests
|
||||||
never modelled the header, so it had shipped silently since WP-49/50.
|
never modelled the header, so it had shipped silently since WP-49/50.
|
||||||
- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
|
- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
|
||||||
where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
|
where **URL identity** becomes the trailing uuid; `Type` takes the internal aanvraag-type
|
||||||
human label (the cross-service join).
|
key (`AanvraagTypeFor`, below) — a real bug (found via a live behandelportal walkthrough,
|
||||||
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
|
fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's
|
||||||
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
|
`AANVRAAG_TYPES` trust boundary always rejected.
|
||||||
|
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, maps each
|
||||||
|
zaak's zaaktype URL back to the internal key via `Zgw:ZaaktypeUrls` (`AanvraagTypeFor` — a
|
||||||
|
local lookup, no Catalogi round-trip), attaches `Authorization: Bearer <jwt>`.
|
||||||
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
|
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
|
||||||
- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the
|
- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the
|
||||||
**inbound** NRC webhook, not a source/mapper: see the dedicated section below.
|
**inbound** NRC webhook, not a source/mapper: see the dedicated section below.
|
||||||
@@ -272,13 +275,13 @@ flag, never a rollen matrix.
|
|||||||
|
|
||||||
## The five ZGW APIs (context for later slices)
|
## The five ZGW APIs (context for later slices)
|
||||||
|
|
||||||
| API | Component | Used by |
|
| API | Component | Used by |
|
||||||
| ------------ | --------- | --------------------------------------------------- |
|
| ------------ | --------- | ---------------------------------------------------------------- |
|
||||||
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
|
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
|
||||||
| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) |
|
| Catalogi | ZTC | WP-50/66 (statustype/roltype/resultaattype for create + besluit) |
|
||||||
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
|
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
|
||||||
| Besluiten | BRC | later (formal decisions) |
|
| Besluiten | BRC | later (formal decisions) |
|
||||||
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
|
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
|
||||||
|
|
||||||
## How to add the next slice
|
## How to add the next slice
|
||||||
|
|
||||||
|
|||||||
@@ -921,6 +921,12 @@ export class ApiClient {
|
|||||||
return response.text().then((_responseText) => {
|
return response.text().then((_responseText) => {
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
} 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 === 404) {
|
} else if (status === 404) {
|
||||||
return response.text().then((_responseText) => {
|
return response.text().then((_responseText) => {
|
||||||
return throwException("Not Found", status, _responseText, _headers);
|
return throwException("Not Found", status, _responseText, _headers);
|
||||||
@@ -1014,6 +1020,12 @@ export class ApiClient {
|
|||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse;
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse;
|
||||||
return result200;
|
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 === 404) {
|
} else if (status === 404) {
|
||||||
return response.text().then((_responseText) => {
|
return response.text().then((_responseText) => {
|
||||||
return throwException("Not Found", status, _responseText, _headers);
|
return throwException("Not Found", status, _responseText, _headers);
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ describe('parseMe (trust boundary)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: WP-66's `aanvraag:beoordelen` (behandelportal) shipped on the `Capability`
|
||||||
|
// type but was never added to this trust-boundary's runtime KNOWN list, so a real
|
||||||
|
// behandelaar's `/me` response had the capability silently dropped and the werkvoorraad
|
||||||
|
// page always denied — every `Capability` union member belongs in KNOWN too.
|
||||||
|
it('recognizes the behandelportal besluit capability (WP-66)', () => {
|
||||||
|
expect(parseMe({ capabilities: ['aanvraag:beoordelen'] })).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: ['aanvraag:beoordelen'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const KNOWN: readonly Capability[] = [
|
|||||||
'stamdata:edit',
|
'stamdata:edit',
|
||||||
'cases:manage',
|
'cases:manage',
|
||||||
'flags:manage',
|
'flags:manage',
|
||||||
|
'aanvraag:beoordelen',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ app = Applicatie.objects.get(client_ids__contains=["bigregister-test"])
|
|||||||
app.autorisaties.filter(component="zrc").delete()
|
app.autorisaties.filter(component="zrc").delete()
|
||||||
app.autorisaties.create(
|
app.autorisaties.create(
|
||||||
component="zrc",
|
component="zrc",
|
||||||
scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"],
|
scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"],
|
||||||
zaaktype="$container_zaaktype_url",
|
zaaktype="$container_zaaktype_url",
|
||||||
max_vertrouwelijkheidaanduiding="openbaar",
|
max_vertrouwelijkheidaanduiding="openbaar",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user