feat(bff): POST /self-service/registrations/{id}/withdraw, owner-scoped (refs #12)
The domain withdraw command carries the caller's bsn and owner-scopes the aggregate; unknown or not-owned is 404 (indistinguishable). The BFF forwards the DigiD token's bsn and relays 204/404. Regenerate the OpenAPI spec + Angular client for the new endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,40 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(
|
||||||
|
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ public interface IDomainClient
|
|||||||
{
|
{
|
||||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
|
||||||
|
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
|
||||||
|
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||||
|
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -47,6 +52,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
|||||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
|
||||||
|
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return false;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
|
||||||
|
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
|
||||||
|
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
||||||
|
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var bsn = user.FindFirstValue("bsn");
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("The token carries no bsn claim.");
|
||||||
|
|
||||||
|
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
|
||||||
|
return withdrawn ? Results.NoContent() : Results.NotFound();
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/self-service/registrations/{id}/withdraw": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/openbaar/register": {
|
"/openbaar/register": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -76,16 +76,19 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
|
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
|
||||||
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
|
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF
|
||||||
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
|
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
|
||||||
// process is a later sub-slice (S-11b).
|
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
|
||||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
|
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(id, out var guid))
|
if (!Guid.TryParse(id, out var guid))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
|
if (string.IsNullOrWhiteSpace(body?.Bsn))
|
||||||
return Results.NoContent();
|
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
|
||||||
|
|
||||||
|
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
|
||||||
|
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||||
@@ -113,6 +116,8 @@ public sealed record SubmitRegistrationRequest(string Bsn);
|
|||||||
|
|
||||||
public sealed record DecideRequest(string Besluit);
|
public sealed record DecideRequest(string Besluit);
|
||||||
|
|
||||||
|
public sealed record WithdrawRequest(string Bsn);
|
||||||
|
|
||||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -2,8 +2,21 @@ using Big.Domain;
|
|||||||
|
|
||||||
namespace Big.Application;
|
namespace Big.Application;
|
||||||
|
|
||||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
|
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
|
||||||
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
|
||||||
|
/// only the registration's own bsn may withdraw it.</summary>
|
||||||
|
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
|
||||||
|
|
||||||
|
/// <summary>The outcome of a withdrawal request.</summary>
|
||||||
|
public enum WithdrawOutcome
|
||||||
|
{
|
||||||
|
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
|
||||||
|
Withdrawn,
|
||||||
|
|
||||||
|
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
|
||||||
|
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
||||||
@@ -16,16 +29,19 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
|
||||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
var registration = await store.GetAsync(command.RegistrationId, ct);
|
||||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
|
|
||||||
|
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
|
||||||
|
if (registration is null || registration.Bsn != command.Bsn)
|
||||||
|
return WithdrawOutcome.NotFound;
|
||||||
|
|
||||||
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
|
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
|
||||||
if (registration.Status == RegistrationStatus.Ingetrokken)
|
if (registration.Status == RegistrationStatus.Ingetrokken)
|
||||||
return;
|
return WithdrawOutcome.Withdrawn;
|
||||||
|
|
||||||
registration.Withdraw();
|
registration.Withdraw();
|
||||||
await store.SaveAsync(registration, ct);
|
await store.SaveAsync(registration, ct);
|
||||||
@@ -33,5 +49,7 @@ public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClie
|
|||||||
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
|
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
|
||||||
if (registration.ProcessInstanceId is not null)
|
if (registration.ProcessInstanceId is not null)
|
||||||
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
|
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
|
||||||
|
|
||||||
|
return WithdrawOutcome.Withdrawn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user