From 8e7a0c86bb5e05a2cab976bddde366529da8f92e Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 17 Jul 2026 09:03:27 +0200 Subject: [PATCH] feat(workflow): Workflow Client reassigns beoordeling to teamlead (S-14, refs #15) Implement the BeoordelingEscaleren escalation capability on the Flowable Workflow Client: acquire escalation jobs, find the still-open Beoordelen task in the instance and swap its candidate group behandelaar -> teamlead via task identity links, and complete the job. Segregated onto IBeoordelingEscalatieClient so the OpenZaak worker is unaffected (ADR-0015). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FlowableWorkflowClient.cs | 69 +++++++++++++++++-- .../IExternalWorkerClient.cs | 20 ++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index 5f886d4..d8933e2 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -15,11 +15,14 @@ namespace Big.Infrastructure; /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) - : IWorkflowClient, IExternalWorkerClient, IUserTaskClient + : IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient { private const string Topic = "OpenZaakAanmaken"; + private const string EscalatieTopic = "BeoordelingEscaleren"; private const string ProcessDefinitionKey = "registratie"; private const string BeoordelenTaskKey = "Beoordelen"; + private const string BehandelaarGroup = "behandelaar"; + private const string TeamleadGroup = "teamlead"; private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; private const string BesluitVariable = "besluit"; @@ -106,14 +109,46 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } - public Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default) + { + var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId); - public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default) - => throw new NotImplementedException(); + var jobs = await PostAsync>( + "external-job-api/acquire/jobs", request, ct) ?? []; - public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default) - => throw new NotImplementedException(); + return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))]; + } + + public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default) + { + // The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting + // boundary timer); find that task in this instance so we can move it to the teamlead. If the + // behandelaar completed it just before the timer fired there is nothing to reassign — a + // best-effort no-op (the timer/completion race, cf. §8.6). + var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey); + var page = await PostAsync( + "service/query/tasks", query, ct); + + var task = page?.Data?.FirstOrDefault(); + if (task is null) + return; + + // Add teamlead, then drop behandelaar: the task now belongs to the teamlead group. + using (var added = await SendAsync( + $"service/runtime/tasks/{task.Id}/identitylinks", + new IdentityLinkRequest(TeamleadGroup, "candidate"), ct)) + added.EnsureSuccessStatusCode(); + + await DeleteAsync( + $"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct); + } + + public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default) + { + using var response = await SendAsync( + $"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct); + response.EnsureSuccessStatusCode(); + } private async Task GetAsync(string path, CancellationToken ct) { @@ -124,6 +159,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti return await response.Content.ReadFromJsonAsync(ct); } + private async Task DeleteAsync(string path, CancellationToken ct) + { + var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path)); + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials()); + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + } + private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); @@ -163,6 +206,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti [property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey, [property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables); + private sealed record TaskByInstanceQueryRequest( + [property: JsonPropertyName("processInstanceId")] string ProcessInstanceId, + [property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey); + + private sealed record IdentityLinkRequest( + [property: JsonPropertyName("group")] string Group, + [property: JsonPropertyName("type")] string Type); + private sealed record ClaimTaskRequest( [property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("assignee")] string Assignee); @@ -202,6 +253,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id); + private sealed record AcquiredEscalatieJob( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("processInstanceId")] string ProcessInstanceId); + private sealed record AcquiredJob( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("variables")] IReadOnlyList Variables) diff --git a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs index c8fa2f6..ade8872 100644 --- a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs +++ b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs @@ -16,3 +16,23 @@ public interface IExternalWorkerClient /// Complete an acquired job, passing the opened zaak URL back into the process. Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default); } + +/// +/// The escalation side of the Workflow Client (S-14): the BeoordelingEscaleren external-worker +/// jobs parked by the 14-day boundary timer on Beoordelen, and the reassignment they drive. +/// Kept separate from (interface segregation) so the OpenZaak +/// worker never sees escalation. Implemented by — the only code +/// that talks to Flowable (§8.2, ADR-0015). +/// +public interface IBeoordelingEscalatieClient +{ + /// Acquire and lock up to BeoordelingEscaleren jobs. + Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default); + + /// Reassign the still-open Beoordelen task in the given process instance from the + /// behandelaar group to teamlead. Best-effort no-op if the task is no longer open. + Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default); + + /// Complete an acquired escalation job so its token reaches the escalation end event. + Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default); +}