fix(backend): resolve besluit endpoint's id via Referentie, not local PK

POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-05 15:20:36 +02:00
co-authored by Claude Opus 5
parent d2c2cffc1f
commit 6cfd70eeeb
12 changed files with 310 additions and 63 deletions
@@ -134,6 +134,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
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
public static IReadOnlyList<Aanvraag> ListAll()
+10 -4
View File
@@ -456,7 +456,13 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
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
// OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a
// ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above
// uses), so resolve the case first and go to the local Aanvraag via its Referentie
// (see ApplicationStore.GetByReferentie).
var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id);
var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null;
var statusTag = a?.ToStatusDto(now).Tag;
if (a is null || statusTag == "Concept") return Results.NotFound();
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
@@ -467,8 +473,8 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
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}", id, besluit);
var updated = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting)!;
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
// 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
@@ -479,7 +485,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
}
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)));
@@ -15,14 +15,17 @@ public sealed record ZgwPage<T>(
/// <summary>
/// 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
/// Catalogi API (cached), 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"/>.
/// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
/// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
/// human zaaktype label, which isn't a value <see cref="ApplicationSummaryDto.Type"/>'s
/// 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
/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution);
/// creating one additionally needs write scope on Zaken.
/// header. Creating/deciding a zaak needs read scope on Catalogi too (statustype/resultaattype/
/// roltype resolution) in addition to write scope on Zaken; a plain read does not.
/// </summary>
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
{
@@ -47,17 +50,22 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
if (bsn is not null)
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
var zaken = await GetAllAsync<ZgwZaak>(url, caller);
var labels = new Dictionary<string, string>();
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;
return zaken.Select(z => ZgwZaakMapper.ToSummaryDto(z, AanvraagTypeFor(z.Zaaktype))).ToList();
}
/// <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>
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
{
@@ -72,13 +80,6 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
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 ------------------------
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
@@ -152,7 +153,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// 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
/// 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) =>
RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
@@ -163,12 +170,25 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
throw new InvalidOperationException(
$"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 toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
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
/// (the eind status) rather than lowest.</summary>
private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl)
@@ -189,14 +209,14 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return first.Url;
}
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
private sealed record Statustype(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer);
private sealed record Roltype([property: JsonPropertyName("url")] string Url);
private sealed record Resultaattype([property: JsonPropertyName("url")] string Url);
private sealed record CreateZaakRequest(
[property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
@@ -210,6 +230,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
[property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
private sealed record CreateResultaatRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("resultaattype")] string Resultaattype);
private sealed record CreateRolRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,