S-09b: Approval flow — temp admin endpoint + status transition to projection (#77)
All checks were successful
All checks were successful
## What & why S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar. ## Changes (bottom-up, each red→green TDD) - **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`. - **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test. - **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status. - **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN. - **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note. ## Key decisions (see ADR-0011) - **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant. - **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+). ## Verification - All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean. - No new compose config (the eindstatus-discovery approach avoided it). - The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally). closes #75 Reviewed-on: #77
This commit was merged in pull request #77.
This commit is contained in:
@@ -41,6 +41,92 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
return new Uri(created.Url);
|
||||
}
|
||||
|
||||
public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||
|
||||
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
||||
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
|
||||
|
||||
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
|
||||
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
|
||||
await PostAsync("/zaken/api/v1/resultaten",
|
||||
new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
|
||||
|
||||
await PostAsync("/zaken/api/v1/statussen",
|
||||
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
|
||||
new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
|
||||
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
|
||||
"Setting the zaak status", ct);
|
||||
}
|
||||
|
||||
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
||||
// a Content-Length instead of a chunked body (as with zaak-create).
|
||||
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||
{
|
||||
Content = JsonContent.Create(dto),
|
||||
};
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
await message.Content.LoadIntoBufferAsync(ct);
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, action, ct);
|
||||
}
|
||||
|
||||
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
|
||||
// is essential for diagnosing a rejected request, so surface it in the exception.
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
|
||||
}
|
||||
|
||||
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
|
||||
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||
{
|
||||
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
|
||||
var results = page.Results ?? [];
|
||||
|
||||
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
|
||||
// highest volgnummer if the flag is absent.
|
||||
var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
|
||||
?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
|
||||
return new Uri(eindstatus.Url);
|
||||
}
|
||||
|
||||
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
|
||||
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||
{
|
||||
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
|
||||
var resultaattype = (page.Results ?? []).FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
|
||||
return new Uri(resultaattype.Url);
|
||||
}
|
||||
|
||||
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
|
||||
private async Task<T> GetCatalogusAsync<T>(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
|
||||
{
|
||||
var query = new Uri(options.BaseUrl,
|
||||
$"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
|
||||
}
|
||||
|
||||
private sealed record ZaakDto(
|
||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||
@@ -50,4 +136,27 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
|
||||
private sealed record ZaakCreatedDto(
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
|
||||
private sealed record StatusDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("statustype")] string Statustype,
|
||||
[property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
|
||||
|
||||
private sealed record StatustypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<StatustypeDto>? Results);
|
||||
|
||||
private sealed record StatustypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
||||
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
|
||||
|
||||
private sealed record ResultaatDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("resultaattype")] string Resultaattype);
|
||||
|
||||
private sealed record ResultaattypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
|
||||
|
||||
private sealed record ResultaattypeDto(
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user