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:
@@ -9,8 +9,8 @@ namespace BigRegister.Api.Data;
|
||||
/// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
|
||||
///
|
||||
/// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c>
|
||||
/// swaps in <c>OpenZaakZaakSource</c>. Slice 1 is read-only; create/update stay on the
|
||||
/// local write path until WP-50. The interface returns the wire DTO (not the domain
|
||||
/// swaps in <c>OpenZaakZaakSource</c>. Slice 1 (WP-49) was read-only; <see cref="CreateZaak"/>
|
||||
/// (WP-50) is the first write. The interface returns the wire DTO (not the domain
|
||||
/// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak
|
||||
/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag.
|
||||
/// </summary>
|
||||
@@ -18,4 +18,15 @@ public interface IZaakSource
|
||||
{
|
||||
/// <summary>Every case, newest-first (the admin cross-owner list, WP-36).</summary>
|
||||
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now);
|
||||
|
||||
/// <summary>
|
||||
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
|
||||
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
|
||||
/// integration side-effect, and its return value is what the submit endpoint hands back to
|
||||
/// the FE (ADR-0001: route the create through the existing submit response DTO, don't add a
|
||||
/// second one). The local source is a pure passthrough of the already-computed local
|
||||
/// reference/status; the OpenZaak source creates a Zaak (+ status + rol) and maps the result
|
||||
/// back into the same shape.
|
||||
/// </summary>
|
||||
(string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,9 @@ public sealed class LocalZaakSource : IZaakSource
|
||||
{
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
|
||||
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
|
||||
|
||||
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record
|
||||
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
|
||||
public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
|
||||
(aanvraag.Referentie!, aanvraag.ToStatusDto(now));
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ api.MapDelete("/applications/{id}", (string id) =>
|
||||
|
||||
// Submit runs the server-owned rules, sets autoApprovable, and transitions the
|
||||
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
|
||||
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
|
||||
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken) =>
|
||||
{
|
||||
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||
if (existing is null) return Results.NotFound();
|
||||
@@ -324,7 +324,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
app.Logger.LogInformation(
|
||||
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
|
||||
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
|
||||
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
|
||||
|
||||
// WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough
|
||||
// of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak
|
||||
// in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
|
||||
// zero FE contract change either way).
|
||||
var (referentie, status) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow);
|
||||
return Results.Ok(new SubmitApplicationResponse(referentie, status));
|
||||
})
|
||||
.Produces<SubmitApplicationResponse>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"_Zgw": "WP-49: set Enabled=true + the URLs/credentials to source cases from a real OpenZaak. Off = local SQLite store (offline POC default).",
|
||||
"_Zgw": "WP-49/50: set Enabled=true + the URLs/credentials/RSINs/zaaktype map to source + create cases against a real OpenZaak. Off = local SQLite store (offline POC default).",
|
||||
"Zgw": {
|
||||
"Enabled": false,
|
||||
"ZrcBaseUrl": "",
|
||||
@@ -14,6 +14,13 @@
|
||||
"ClientId": "",
|
||||
"Secret": "",
|
||||
"UserId": "big-register-bff",
|
||||
"UserRepresentation": "BIG-register BFF"
|
||||
"UserRepresentation": "BIG-register BFF",
|
||||
"Bronorganisatie": "",
|
||||
"VerantwoordelijkeOrganisatie": "",
|
||||
"ZaaktypeUrls": {
|
||||
"registratie": "",
|
||||
"herregistratie": "",
|
||||
"intake": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Api.Zgw;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
@@ -59,16 +60,98 @@ public class OpenZaakZaakSourceTests
|
||||
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back()
|
||||
{
|
||||
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
|
||||
var handler = new StubHandler(url => url switch
|
||||
{
|
||||
_ when url == $"{ZrcBase}/zaken" => $$"""
|
||||
{ "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
|
||||
"zaaktype": "{{zaaktypeUrl}}", "startdatum": "2026-07-28",
|
||||
"einddatum": null, "registratiedatum": "2026-07-28" }
|
||||
""",
|
||||
_ 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}/roltypen") => """
|
||||
{ "count": 1, "next": null,
|
||||
"results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] }
|
||||
""",
|
||||
_ when url == $"{ZrcBase}/statussen" => "{}",
|
||||
_ when url == $"{ZrcBase}/rollen" => "{}",
|
||||
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
||||
});
|
||||
|
||||
var options = new ZgwOptions
|
||||
{
|
||||
ZrcBaseUrl = ZrcBase,
|
||||
ZtcBaseUrl = ZtBase,
|
||||
ClientId = "c",
|
||||
Secret = "s",
|
||||
Bronorganisatie = "123443210",
|
||||
VerantwoordelijkeOrganisatie = "123443210",
|
||||
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",
|
||||
};
|
||||
|
||||
var (referentie, status) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
Assert.Equal("BIG-2026-000123", referentie);
|
||||
Assert.Equal("InBehandeling", status.Tag);
|
||||
Assert.Equal("BIG-2026-000123", status.Referentie);
|
||||
|
||||
string BodyOf(string url) => handler.Bodies[handler.Requests.LastIndexOf(url)];
|
||||
|
||||
// Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie.
|
||||
var zaakBody = BodyOf($"{ZrcBase}/zaken");
|
||||
Assert.Contains(zaaktypeUrl, zaakBody);
|
||||
Assert.Contains("123443210", zaakBody);
|
||||
Assert.Contains("BIG-2026-000123", zaakBody);
|
||||
|
||||
// Status: points at the created zaak's URL and the resolved statustype.
|
||||
var statusBody = BodyOf($"{ZrcBase}/statussen");
|
||||
Assert.Contains($"{ZrcBase}/zaken/uuid-new", statusBody);
|
||||
Assert.Contains("statustypen/st-1", statusBody);
|
||||
|
||||
// Rol: points at the created zaak, the resolved initiator roltype, and the BSN.
|
||||
var rolBody = BodyOf($"{ZrcBase}/rollen");
|
||||
Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody);
|
||||
Assert.Contains("roltypen/rt-initiator", rolBody);
|
||||
Assert.Contains("111222333", rolBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateZaak_throws_when_the_aanvraag_type_has_no_configured_zaaktype()
|
||||
{
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var handler = new StubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<string, string> respond) : HttpMessageHandler
|
||||
{
|
||||
public List<string> Requests { get; } = new();
|
||||
public List<string?> AuthSchemes { get; } = new();
|
||||
public List<string> Bodies { get; } = new();
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
Requests.Add(url);
|
||||
AuthSchemes.Add(request.Headers.Authorization?.Scheme);
|
||||
Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? "");
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
|
||||
|
||||
@@ -64,6 +64,8 @@ up front — the migration stance ADR-0001 already prescribes.
|
||||
- **Shipped with this ADR (WP-49):** `IZaakSource` + `LocalZaakSource` (default) +
|
||||
`OpenZaakZaakSource` (config-gated), the `Zgw/` client (`ZgwOptions`, `ZgwTokenProvider`,
|
||||
`ZgwZaakMapper`), and the reference guide [openzaak-integration.md](../openzaak-integration.md).
|
||||
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), create-zaak (WP-50),
|
||||
Documenten/DRC upload + link (WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to
|
||||
docker-compose.
|
||||
- **Also shipped (WP-50):** `IZaakSource.CreateZaak` — the first write. Submitting an aanvraag
|
||||
now also creates a Zaak + Status + Rol in OpenZaak when `Zgw:Enabled=true`, routed through the
|
||||
existing submit endpoint with zero DTO change (same seam, same anti-corruption boundary).
|
||||
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), Documenten/DRC upload + link
|
||||
(WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to docker-compose.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# OpenZaak / ZGW integration — how the BFF connects (& how to extend)
|
||||
|
||||
How the BFF sources cases from a real **OpenZaak** (ZGW APIs) while the frontend stays
|
||||
unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page
|
||||
is _how the seam is built and how to add the next slice_. Built in
|
||||
[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken).
|
||||
How the BFF sources (and now creates) cases against a real **OpenZaak** (ZGW APIs) while the
|
||||
frontend stays unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md);
|
||||
this page is _how the seam is built and how to add the next slice_. Built in
|
||||
[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken) and
|
||||
[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (the first write: create-zaak).
|
||||
|
||||
## The one rule: OpenZaak sits behind the BFF, never in the browser
|
||||
|
||||
@@ -14,14 +15,48 @@ with **zero frontend change and no api-client drift**.
|
||||
|
||||
## The seam (data source by config)
|
||||
|
||||
- `Data/IZaakSource.cs` — the cases READ interface. Returns the existing
|
||||
`ApplicationSummaryDto`, so each implementation owns its own mapping.
|
||||
- `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and
|
||||
`CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping.
|
||||
- `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore`
|
||||
(offline, unchanged behaviour).
|
||||
(offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit
|
||||
endpoint already computed locally — no external call.
|
||||
- `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`.
|
||||
`CreateZaak` posts a Zaak, then a Status, then a Rol (see below).
|
||||
- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>()
|
||||
else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` endpoint resolves
|
||||
`IZaakSource` from DI — routes + DTOs untouched.
|
||||
else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` GET and the
|
||||
`/applications/{id}/submit` POST both resolve `IZaakSource` from DI — routes + DTOs
|
||||
untouched either way.
|
||||
|
||||
## Create-zaak (WP-50) — the first write
|
||||
|
||||
`POST /applications/{id}/submit` already persists the aanvraag locally (`ApplicationStore.Submit`
|
||||
— unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays
|
||||
local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint
|
||||
never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint
|
||||
just asks the seam for `(Referentie, Status)` and returns exactly that in the unchanged
|
||||
`SubmitApplicationResponse`. Under the default (local) source this returns precisely what was
|
||||
just computed; under OpenZaak, three calls happen in order:
|
||||
|
||||
1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]`
|
||||
(OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie`
|
||||
(RSIN) from config, `identificatie` set to the **same** reference `ApplicationStore.Submit`
|
||||
already generated — so the human-readable reference matches in both places, not two
|
||||
independently-generated ones.
|
||||
2. **POST status** (`{ZrcBaseUrl}/statussen`) — `statustype` resolved via a Catalogi GET
|
||||
(`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened.
|
||||
3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET
|
||||
(`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn`
|
||||
set to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53).
|
||||
|
||||
The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the
|
||||
same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak
|
||||
(`ZgwZaakMapper.ToCreatedStatusDto`).
|
||||
|
||||
ponytail shortcuts, marked at the call sites: (a) "first statustype/roltype Catalogi returns"
|
||||
rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial
|
||||
status and initiator role; (b) no compensating transaction — if any ZGW call throws, the
|
||||
aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend;
|
||||
a production arc needs retry/reconciliation or an outbox before trusting this dual-write).
|
||||
|
||||
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
|
||||
|
||||
@@ -75,7 +110,14 @@ path async if OpenZaak becomes the default.
|
||||
"ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1",
|
||||
"ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1",
|
||||
"ClientId": "big-register", "Secret": "<from a secret store>",
|
||||
"UserId": "<session user>", "UserRepresentation": "<session name>"
|
||||
"UserId": "<session user>", "UserRepresentation": "<session name>",
|
||||
// WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map.
|
||||
"Bronorganisatie": "<RSIN>", "VerantwoordelijkeOrganisatie": "<RSIN>",
|
||||
"ZaaktypeUrls": {
|
||||
"registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
|
||||
"herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
|
||||
"intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -111,9 +153,9 @@ Principles this demonstrates:
|
||||
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be
|
||||
complete on day one, but its shortcuts should be visible.
|
||||
|
||||
Caveat: today only the cases **read** path has a source interface (`IZaakSource`). Other BFF
|
||||
Caveat: `IZaakSource` now covers the cases **read + create** path (WP-49/50). Other BFF
|
||||
endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not
|
||||
yet swappable. That is the WP-50/51/52 roadmap, plus the two cross-cutting WPs the arc needs for
|
||||
yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for
|
||||
production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN
|
||||
is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything
|
||||
is fixture/mock-tested against no live instance).
|
||||
|
||||
Reference in New Issue
Block a user