feat(zgw): OpenZaak create-zaak, first write slice (WP-50)
Extends the IZaakSource seam (WP-49, read-only) with CreateZaak: submitting
an aanvraag now also registers a Zaak + Status + Rol in OpenZaak when
Zgw:Enabled=true, routed through the existing /applications/{id}/submit
endpoint with the FE response DTO unchanged (ADR-0001/ADR-0005 — the
endpoint never branches on the config flag itself, DI already picked the
implementation).
- ZgwOptions gains a Type→zaaktype-URL map + the two RSINs a Zaak needs.
- LocalZaakSource.CreateZaak is a pure passthrough of what the endpoint
already computes locally (zero behaviour change for the offline default).
- OpenZaakZaakSource.CreateZaak POSTs the zaak (identificatie = the same
local reference, so both stay in sync), resolves + POSTs the initial
status and the initiator rol (BSN) via Catalogi lookups, and maps the
result back into the submit response.
- Marked ponytail shortcuts: first-statustype/roltype-Catalogi-returns
(no per-type config) and no compensating transaction on partial failure
— both fine for a first slice against a demo backend.
Verified: full `npm run ci` green, zero api-client drift, 144/144 backend
tests (142 existing + 2 new stub-handler tests asserting the POST bodies
+ type→zaaktype mapping per the acceptance criteria).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,15 @@ public sealed record ZgwPage<T>(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<T> Results);
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49). 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"/>.
|
||||
/// 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"/>.
|
||||
///
|
||||
/// 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. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution);
|
||||
/// creating one additionally needs write scope on Zaken.
|
||||
/// </summary>
|
||||
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
|
||||
{
|
||||
@@ -78,5 +80,108 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
|
||||
}
|
||||
|
||||
// --- 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
|
||||
/// initial status → resolve + POST the initiator rol (BSN). Sync-over-async for the same
|
||||
/// reason as <see cref="ListCases"/> (see the ponytail note there) — a submit is already a
|
||||
/// single request/response round trip, so no extra concurrency concern.
|
||||
///
|
||||
/// ponytail: no compensating transaction — if any ZGW call here throws, the aanvraag is
|
||||
/// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak.
|
||||
/// Acceptable for a first write slice against a demo backend; a production arc would need a
|
||||
/// retry/reconciliation story (or an outbox) before this dual-write can be trusted.
|
||||
public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
|
||||
CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult();
|
||||
|
||||
private async Task<(string Referentie, AanvraagStatusDto Status)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now)
|
||||
{
|
||||
if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
|
||||
throw new InvalidOperationException(
|
||||
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
|
||||
|
||||
var zaak = await PostAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest(
|
||||
Zaaktype: zaaktypeUrl,
|
||||
Bronorganisatie: options.Bronorganisatie,
|
||||
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
|
||||
Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
|
||||
Identificatie: aanvraag.Referentie
|
||||
?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")));
|
||||
|
||||
var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
|
||||
await PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen",
|
||||
new CreateStatusRequest(zaak.Url, statustypeUrl, now));
|
||||
|
||||
var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
|
||||
await PostAsync<JsonElement>($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest(
|
||||
Zaak: zaak.Url,
|
||||
BetrokkeneType: "natuurlijk_persoon",
|
||||
Roltype: roltypeUrl,
|
||||
Roltoelichting: "Initiator",
|
||||
BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner)));
|
||||
|
||||
return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie));
|
||||
}
|
||||
|
||||
// ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype
|
||||
// Catalogi returns for the zaaktype, rather than a fully-configured per-type mapping like
|
||||
// ZaaktypeUrls — good enough while a zaaktype has exactly one initial status and one
|
||||
// initiator role (the normal case); add per-type config if that ever stops holding.
|
||||
private async Task<string> FirstStatustypeUrlAsync(string zaaktypeUrl)
|
||||
{
|
||||
var page = await GetAsync<ZgwPage<Statustype>>(
|
||||
$"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
|
||||
var first = page.Results.OrderBy(s => s.Volgnummer).FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}.");
|
||||
return first.Url;
|
||||
}
|
||||
|
||||
private async Task<string> FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl)
|
||||
{
|
||||
var page = await GetAsync<ZgwPage<Roltype>>(
|
||||
$"{options.ZtcBaseUrl}/roltypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}&omschrijvingGeneriek=initiator");
|
||||
var first = page.Results.FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No 'initiator' roltype found for zaaktype {zaaktypeUrl}.");
|
||||
return first.Url;
|
||||
}
|
||||
|
||||
private async Task<T> PostAsync<T>(string url, object body)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint());
|
||||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
using var res = await http.SendAsync(req);
|
||||
res.EnsureSuccessStatusCode();
|
||||
return (await res.Content.ReadFromJsonAsync<T>())
|
||||
?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
|
||||
}
|
||||
|
||||
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 CreateZaakRequest(
|
||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
|
||||
[property: JsonPropertyName("startdatum")] DateOnly Startdatum,
|
||||
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||
|
||||
private sealed record CreateStatusRequest(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("statustype")] string Statustype,
|
||||
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet);
|
||||
|
||||
private sealed record CreateRolRequest(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
|
||||
[property: JsonPropertyName("roltype")] string Roltype,
|
||||
[property: JsonPropertyName("roltoelichting")] string Roltoelichting,
|
||||
[property: JsonPropertyName("betrokkeneIdentificatie")] BetrokkeneIdentificatie BetrokkeneIdentificatie);
|
||||
|
||||
private sealed record BetrokkeneIdentificatie([property: JsonPropertyName("inpBsn")] string InpBsn);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,16 @@ public sealed class ZgwOptions
|
||||
|
||||
/// <summary>Human-readable end-user name for the audit trail (JWT <c>user_representation</c>).</summary>
|
||||
public string UserRepresentation { get; init; } = "BIG-register BFF";
|
||||
|
||||
/// <summary>Aanvraag <c>Type</c> (registratie/herregistratie/intake) → zaaktype URL (Catalogi),
|
||||
/// so create-zaak (WP-50) knows which zaaktype to open per wizard. OpenZaak validates the URL
|
||||
/// by fetching it, so an unconfigured or wrong entry fails loudly at create time.</summary>
|
||||
public Dictionary<string, string> ZaaktypeUrls { get; init; } = new();
|
||||
|
||||
/// <summary>RSIN of the organisation registering the zaak (<c>bronorganisatie</c>, WP-50).</summary>
|
||||
public string Bronorganisatie { get; init; } = "";
|
||||
|
||||
/// <summary>RSIN of the organisation responsible for the zaak (<c>verantwoordelijkeOrganisatie</c>,
|
||||
/// WP-50) — usually the same RSIN as <see cref="Bronorganisatie"/>.</summary>
|
||||
public string VerantwoordelijkeOrganisatie { get; init; } = "";
|
||||
}
|
||||
|
||||
@@ -55,4 +55,9 @@ public static class ZgwZaakMapper
|
||||
// becomes midnight UTC so the FE's date parsing sees the same format either backend.
|
||||
private static string Iso(DateOnly d) =>
|
||||
d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o");
|
||||
|
||||
/// <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>
|
||||
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
|
||||
new("InBehandeling", Referentie: identificatie, Manual: true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user