feat(acl): set a zaak to its eindstatus via ZGW + POST /statussen endpoint (refs #75)
The gateway resolves the zaaktype's eindstatus from the catalogus (isEindstatus, falling back to the highest volgnummer) and POSTs a status against the zaak. Exposed as POST /statussen for the domain's approve use case. Adds an integration test that sets the eindstatus against a real OpenZaak and verifies the zaak's current status. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,8 +24,18 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT
|
|||||||
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
|
||||||
|
// URL; the ACL owns the ZGW statustype resolution (§8.1).
|
||||||
|
app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public sealed record OpenZaakRequest(string Bsn);
|
public sealed record OpenZaakRequest(string Bsn);
|
||||||
|
|
||||||
|
public sealed record SetStatusRequest(string ZaakUrl);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -17,4 +17,15 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
|
|
||||||
return gateway.OpenZaakAsync(request, ct);
|
return gateway.OpenZaakAsync(request, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
||||||
|
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||||
|
/// </summary>
|
||||||
|
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,11 @@ namespace Acl.Application;
|
|||||||
public interface IZaakGateway
|
public interface IZaakGateway
|
||||||
{
|
{
|
||||||
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
|
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the given zaak to the <em>eindstatus</em> (final statustype) of the supplied zaaktype —
|
||||||
|
/// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from
|
||||||
|
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,56 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(created.Url);
|
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);
|
||||||
|
|
||||||
|
using var message = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen"))
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new StatusDto(
|
||||||
|
zaakUrl.ToString(),
|
||||||
|
eindstatus.ToString(),
|
||||||
|
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
|
||||||
|
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))),
|
||||||
|
};
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
// The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the
|
||||||
|
// body so uwsgi gets a Content-Length instead of a chunked body.
|
||||||
|
await message.Content.LoadIntoBufferAsync(ct);
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
|
||||||
|
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = new Uri(options.BaseUrl,
|
||||||
|
"/catalogi/api/v1/statustypen?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);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var page = await response.Content.ReadFromJsonAsync<StatustypePage>(ct)
|
||||||
|
?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record ZaakDto(
|
private sealed record ZaakDto(
|
||||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||||
@@ -50,4 +100,17 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
|
|
||||||
private sealed record ZaakCreatedDto(
|
private sealed record ZaakCreatedDto(
|
||||||
[property: JsonPropertyName("url")] string Url);
|
[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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,43 @@ public sealed class OpenZaakFixture : IDisposable
|
|||||||
return JsonDocument.Parse(json).RootElement.Clone();
|
return JsonDocument.Parse(json).RootElement.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers.</summary>
|
||||||
|
public async Task<JsonElement> GetJsonAsync(Uri url, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
|
||||||
|
|
||||||
|
using var response = await Http.SendAsync(message, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return JsonDocument.Parse(json).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets.</summary>
|
||||||
|
public async Task<Uri> FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = new Uri(BaseUrl,
|
||||||
|
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
||||||
|
var page = await GetJsonAsync(query, ct);
|
||||||
|
var results = page.GetProperty("results");
|
||||||
|
|
||||||
|
Uri? fallback = null;
|
||||||
|
var highest = int.MinValue;
|
||||||
|
foreach (var st in results.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean())
|
||||||
|
return new Uri(st.GetProperty("url").GetString()!);
|
||||||
|
var volgnummer = st.GetProperty("volgnummer").GetInt32();
|
||||||
|
if (volgnummer > highest)
|
||||||
|
{
|
||||||
|
highest = volgnummer;
|
||||||
|
fallback = new Uri(st.GetProperty("url").GetString()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
|
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
|
||||||
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
|
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
|
||||||
private string MintToken()
|
private string MintToken()
|
||||||
|
|||||||
@@ -42,4 +42,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
||||||
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype()
|
||||||
|
{
|
||||||
|
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||||
|
Assert.True(zaaktype is not null,
|
||||||
|
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
|
||||||
|
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
||||||
|
|
||||||
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
|
||||||
|
Bronorganisatie: "517439943",
|
||||||
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
|
Zaaktype: zaaktype!,
|
||||||
|
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)));
|
||||||
|
|
||||||
|
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
|
||||||
|
// The zaak now carries a current status, and it is the zaaktype's eindstatus.
|
||||||
|
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||||
|
var statusUrl = zaak.GetProperty("status").GetString();
|
||||||
|
Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status");
|
||||||
|
|
||||||
|
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
|
||||||
|
var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!);
|
||||||
|
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user