## What & why Third sub-slice of **S-11 · Withdrawal (Flow 3)** (#12) — the **owner-scoped BFF withdraw endpoint** (backend). S-11a/b made a withdrawal transition the aggregate and cancel the workflow; this adds the citizen-facing entry point through the BFF, gated to the registration's owner. - **Domain**: `WithdrawRegistrationCommand` carries the caller's `bsn`; the handler returns a `WithdrawOutcome` and refuses a bsn that doesn't own the registration. Unknown and not-owned are **both 404** (indistinguishable — ownership isn't revealed). `POST /registrations/{id}/withdraw` takes `{bsn}` and maps the outcome (204/404). - **BFF**: `POST /self-service/registrations/{id}/withdraw` (DigiD-authenticated) forwards the token's `bsn` to the domain and relays 204/404. The BFF authenticates; the domain owner-scopes (an aggregate invariant, not the domain doing auth). - OpenAPI spec + Angular client regenerated for the new endpoint. - `run-domain-check.sh` withdrawal step now sends the owner `bsn` (verify-stack). Refs #12 — the self-service "trek aanvraag in" button + e2e (S-11c-2) closes it. ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing tests committed before the implementation. - [x] Implementation makes the tests pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected. - [x] No ADR needed (owner-scoping is an aggregate invariant; no boundary change). - [x] Docs — the user-visible demo note lands with S-11c-2. ## Notes for reviewers - **Full local gate run before pushing this time** (lessons from #89): `dotnet format --verify-no-changes` clean; `make unit` green — Acl 27, EventSubscriber 19, BFF 30, Acceptance 9, Big 95; `api-client` lint+test green. - Owner mismatch returns 404 (not 403) so the portal can't be used to probe which references exist. Reviewed-on: #90
56 lines
2.6 KiB
C#
56 lines
2.6 KiB
C#
using Big.Domain;
|
|
|
|
namespace Big.Application;
|
|
|
|
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
|
|
/// <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>
|
|
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
|
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
|
|
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
|
|
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
|
|
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
|
|
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
|
|
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
|
|
/// </summary>
|
|
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
|
{
|
|
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(command);
|
|
|
|
var registration = await store.GetAsync(command.RegistrationId, ct);
|
|
|
|
// 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.
|
|
if (registration.Status == RegistrationStatus.Ingetrokken)
|
|
return WithdrawOutcome.Withdrawn;
|
|
|
|
registration.Withdraw();
|
|
await store.SaveAsync(registration, ct);
|
|
|
|
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
|
|
if (registration.ProcessInstanceId is not null)
|
|
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
|
|
|
|
return WithdrawOutcome.Withdrawn;
|
|
}
|
|
}
|