From 69a5712d78c00d8e958a3627391b3ff1c51d0b2e Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 28 Apr 2026 14:40:13 -0400 Subject: [PATCH 1/6] FOUR-28073 Canceled case still progresses to Completed Description: Race: cancel closed only in-memory tokens while parallel gateways could create new ACTIVE tokens afterward; completing them overwrote CANCELED with COMPLETED. Bulk-close stray tokens on cancel, block task completion and new activations when status is CANCELED, skip COMPLETED persistence if already canceled, guard script completion, and rethrow HttpResponseException from BPMN actions so 422 responses are not swallowed as request errors. Related tickets: https://processmaker.atlassian.net/browse/FOUR-28073 --- ProcessMaker/Jobs/BpmnAction.php | 3 +++ ProcessMaker/Jobs/CancelRequest.php | 16 ++++++++++++++ ProcessMaker/Jobs/RunNayraScriptTask.php | 18 ++++++++++++++++ .../Nayra/Managers/WorkflowManagerDefault.php | 9 ++++++++ .../ExecutionInstanceRepository.php | 5 +++++ ProcessMaker/Repositories/TokenRepository.php | 21 +++++++++++++++++++ 6 files changed, 72 insertions(+) diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f78ddaf647..7d873d1f91 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\App; @@ -76,6 +77,8 @@ public function handle() if ($this->processId !== 'non_persistent_process') { HandleRedirectListener::sendRedirectToEvent(); } + } catch (HttpResponseException $exception) { + throw $exception; } catch (HttpABTestingException $exception) { Log::error($exception->getMessage()); throw $exception; diff --git a/ProcessMaker/Jobs/CancelRequest.php b/ProcessMaker/Jobs/CancelRequest.php index d7a829c29d..bcaec47006 100644 --- a/ProcessMaker/Jobs/CancelRequest.php +++ b/ProcessMaker/Jobs/CancelRequest.php @@ -2,10 +2,13 @@ namespace ProcessMaker\Jobs; +use Carbon\Carbon; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Notification; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Notifications\ProcessCanceledNotification; +use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Repositories\ExecutionInstanceRepository; use ProcessMaker\Repositories\TokenRepository; @@ -49,5 +52,18 @@ public function action(ProcessRequest $instance) foreach ($instance->getTokens()->toArray() as $token) { $tokenRepo->store($token); } + + // Close tokens that were created after the in-memory snapshot (race with parallel + // gateways / concurrent completions). See FOUR-28073. + ProcessRequestToken::query() + ->where('process_request_id', $instance->getKey()) + ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) + ->update([ + 'status' => ActivityInterface::TOKEN_STATE_CLOSED, + 'completed_at' => Carbon::now(), + 'due_at' => null, + 'riskchanges_at' => null, + 'user_id' => null, + ]); } } diff --git a/ProcessMaker/Jobs/RunNayraScriptTask.php b/ProcessMaker/Jobs/RunNayraScriptTask.php index ceea72f896..325416bd77 100644 --- a/ProcessMaker/Jobs/RunNayraScriptTask.php +++ b/ProcessMaker/Jobs/RunNayraScriptTask.php @@ -12,6 +12,7 @@ use ProcessMaker\Exception\ScriptException; use ProcessMaker\Facades\WorkflowManager; use ProcessMaker\Managers\DataManager; +use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptExecutor; @@ -99,10 +100,27 @@ public function handle() $response = $script->runScript($data, $configuration, $token->getId(), $errorHandling->timeout()); + if (ProcessRequest::query()->whereKey($instance->getKey())->value('status') === 'CANCELED') { + Log::info('Skipping script task completion because the request was canceled.', [ + 'process_request_id' => $instance->getKey(), + 'token_id' => $token->getKey(), + ]); + + return; + } + // Dispatch complete task action WorkflowManager::completeTask($processModel, $instance, $token, $response['output']); } catch (ConfigurationException $exception) { $output = $exception->getMessageForData($token); + if (ProcessRequest::query()->whereKey($instance->getKey())->value('status') === 'CANCELED') { + Log::info('Skipping script task completion because the request was canceled.', [ + 'process_request_id' => $instance->getKey(), + 'token_id' => $token->getKey(), + ]); + + return; + } WorkflowManager::completeTask($processModel, $instance, $token, $output); } catch (Throwable $exception) { Log::error('Script failed: ' . $scriptRef . ' - ' . $exception->getMessage()); diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php index 0f757aa7e2..453346f4f9 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Nayra\Managers; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; @@ -71,6 +72,14 @@ public function completeTask(Definitions $definitions, ExecutionInstanceInterfac //Validate data $element = $token->getDefinition(true); $this->validateData($data, $definitions, $element); + if ($instance instanceof ProcessRequest) { + $status = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); + if ($status === 'CANCELED') { + throw new HttpResponseException(response()->json([ + 'message' => __('This request has been canceled. The task cannot be completed.'), + ], 422)); + } + } CompleteActivity::dispatchSync($definitions, $instance, $token, $data); } diff --git a/ProcessMaker/Repositories/ExecutionInstanceRepository.php b/ProcessMaker/Repositories/ExecutionInstanceRepository.php index 90212e373a..01666f5856 100644 --- a/ProcessMaker/Repositories/ExecutionInstanceRepository.php +++ b/ProcessMaker/Repositories/ExecutionInstanceRepository.php @@ -260,6 +260,11 @@ public function persistInstanceCompleted(ExecutionInstanceInterface $instance) return; } + $currentStatus = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); + if ($currentStatus === 'CANCELED') { + return; + } + // Save completed instance $instance->status = 'COMPLETED'; $instance->completed_at = Carbon::now(); diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 77ad20fba9..dfb39d0bd9 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -90,6 +90,27 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter if ($process->isNonPersistent()) { return; } + $instance = $token->getInstance(); + if ($instance instanceof ProcessRequest) { + $currentStatus = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); + if ($currentStatus === 'CANCELED') { + $token->status = ActivityInterface::TOKEN_STATE_CLOSED; + $token->element_id = $activity->getId(); + $token->element_type = $this->getActivityType($activity); + $token->element_name = $activity->getName(); + $token->process_id = $instance->process_id; + $token->process_request_id = $instance->getKey(); + $token->user_id = null; + $token->due_at = null; + $token->riskchanges_at = null; + $token->completed_at = Carbon::now(); + $token->updateTokenProperties(); + $token->saveOrFail(); + $token->setId($token->getKey()); + + return; + } + } $token->status = ActivityInterface::TOKEN_STATE_ACTIVE; $token->element_id = $activity->getId(); $token->element_type = $this->getActivityType($activity); From 8ea654c83dd69bc8805bf94698d4003abc9e0c8c Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 28 Apr 2026 14:43:40 -0400 Subject: [PATCH 2/6] fix --- ProcessMaker/Jobs/CancelRequest.php | 2 +- ProcessMaker/Jobs/RunNayraScriptTask.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Jobs/CancelRequest.php b/ProcessMaker/Jobs/CancelRequest.php index bcaec47006..c05317d0cd 100644 --- a/ProcessMaker/Jobs/CancelRequest.php +++ b/ProcessMaker/Jobs/CancelRequest.php @@ -7,8 +7,8 @@ use Illuminate\Support\Facades\Notification; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; -use ProcessMaker\Notifications\ProcessCanceledNotification; use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; +use ProcessMaker\Notifications\ProcessCanceledNotification; use ProcessMaker\Repositories\ExecutionInstanceRepository; use ProcessMaker\Repositories\TokenRepository; diff --git a/ProcessMaker/Jobs/RunNayraScriptTask.php b/ProcessMaker/Jobs/RunNayraScriptTask.php index 325416bd77..2fc260dc2d 100644 --- a/ProcessMaker/Jobs/RunNayraScriptTask.php +++ b/ProcessMaker/Jobs/RunNayraScriptTask.php @@ -37,7 +37,7 @@ class RunNayraScriptTask implements ShouldQueue /** * Create a new job instance. * - * @param \ProcessMaker\Models\ProcessRequestToken $token + * @param ProcessRequestToken $token * @param array $data */ public function __construct(TokenInterface $token) From f8089a58c4e7d219529545117241b6b05ec693ca Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 28 Apr 2026 14:46:22 -0400 Subject: [PATCH 3/6] Improvements --- ProcessMaker/Jobs/CancelRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProcessMaker/Jobs/CancelRequest.php b/ProcessMaker/Jobs/CancelRequest.php index c05317d0cd..edfa5fadf7 100644 --- a/ProcessMaker/Jobs/CancelRequest.php +++ b/ProcessMaker/Jobs/CancelRequest.php @@ -54,7 +54,7 @@ public function action(ProcessRequest $instance) } // Close tokens that were created after the in-memory snapshot (race with parallel - // gateways / concurrent completions). See FOUR-28073. + // gateways / concurrent completions). ProcessRequestToken::query() ->where('process_request_id', $instance->getKey()) ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) From e4553eae1a2494664c7ead4a4180a264efd0f0d0 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 6 May 2026 15:40:53 -0400 Subject: [PATCH 4/6] fix observations. --- .../Nayra/Managers/WorkflowManagerDefault.php | 26 +++++++++++++------ .../Managers/WorkflowManagerRabbitMq.php | 7 +++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php index 453346f4f9..43f98c4087 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php @@ -72,17 +72,27 @@ public function completeTask(Definitions $definitions, ExecutionInstanceInterfac //Validate data $element = $token->getDefinition(true); $this->validateData($data, $definitions, $element); - if ($instance instanceof ProcessRequest) { - $status = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); - if ($status === 'CANCELED') { - throw new HttpResponseException(response()->json([ - 'message' => __('This request has been canceled. The task cannot be completed.'), - ], 422)); - } - } + $this->assertProcessRequestNotCanceled($instance); CompleteActivity::dispatchSync($definitions, $instance, $token, $data); } + /** + * Block task completion when the request was canceled (FOUR-28073). + * Used by the default manager and by WorkflowManagerRabbitMq / Kafka. + */ + protected function assertProcessRequestNotCanceled(ExecutionInstanceInterface $instance): void + { + if (!$instance instanceof ProcessRequest) { + return; + } + $status = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); + if ($status === 'CANCELED') { + throw new HttpResponseException(response()->json([ + 'message' => __('This request has been canceled. The task cannot be completed.'), + ], 422)); + } + } + /** * Fail a task. * diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php b/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php index d66d45d5aa..baaa8d13a8 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php @@ -136,6 +136,7 @@ public function completeTask(Definitions $definitions, ExecutionInstanceInterfac // Validate data $element = $token->getDefinition(true); $this->validateData($data, $definitions, $element); + $this->assertProcessRequestNotCanceled($instance); // Get complementary information $version = $instance->process_version_id; @@ -381,6 +382,12 @@ public function handleServiceTask(ProcessRequestToken $token, RunNayraServiceTas private function dispatchActionForServiceTask($version, $token, $response, $state, $userId) { + $instance = $token->processRequest + ?? ProcessRequest::query()->whereKey($token->process_request_id)->first(); + if ($instance) { + $this->assertProcessRequestNotCanceled($instance); + } + $this->dispatchAction([ 'bpmn' => $version, 'action' => self::ACTION_COMPLETE_TASK, From 4b8b6e5161adefdae1d0659ef1eb233f58caa758 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 6 May 2026 16:42:04 -0400 Subject: [PATCH 5/6] Fix observations and discard unnecessary changes. --- .../Api/ProcessRequestController.php | 13 ++++++++++++ ProcessMaker/Jobs/BpmnAction.php | 3 --- ProcessMaker/Jobs/CancelRequest.php | 4 ++-- ProcessMaker/Jobs/RunNayraScriptTask.php | 18 ---------------- .../Nayra/Managers/WorkflowManagerDefault.php | 19 ----------------- .../Managers/WorkflowManagerRabbitMq.php | 7 ------- .../ExecutionInstanceRepository.php | 5 ----- ProcessMaker/Repositories/TokenRepository.php | 21 ------------------- 8 files changed, 15 insertions(+), 75 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index 6508bba81b..562ba325d0 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php @@ -609,6 +609,19 @@ private function cancelRequestToken(ProcessRequest $request) // Close process request $request->status = 'CANCELED'; $request->save(); + + // Close any token still open after status is CANCELED (race: task submit commits after CancelRequest job). + ProcessRequestToken::query() + ->where('process_request_id', $request->getKey()) + ->where('status', '!=', 'CLOSED') + ->update([ + 'status' => 'CLOSED', + 'completed_at' => now(), + 'due_at' => null, + 'riskchanges_at' => null, + 'user_id' => null, + ]); + // Update case status CaseUpdateStatus::dispatchSync($request); diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index 7d873d1f91..f78ddaf647 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\App; @@ -77,8 +76,6 @@ public function handle() if ($this->processId !== 'non_persistent_process') { HandleRedirectListener::sendRedirectToEvent(); } - } catch (HttpResponseException $exception) { - throw $exception; } catch (HttpABTestingException $exception) { Log::error($exception->getMessage()); throw $exception; diff --git a/ProcessMaker/Jobs/CancelRequest.php b/ProcessMaker/Jobs/CancelRequest.php index edfa5fadf7..fb154344bf 100644 --- a/ProcessMaker/Jobs/CancelRequest.php +++ b/ProcessMaker/Jobs/CancelRequest.php @@ -53,8 +53,8 @@ public function action(ProcessRequest $instance) $tokenRepo->store($token); } - // Close tokens that were created after the in-memory snapshot (race with parallel - // gateways / concurrent completions). + // Tokens created after the in-memory snapshot (e.g. another user submits a task while + // cancel is confirmed) must still be closed so no ACTIVE task remains on a CANCELED request. ProcessRequestToken::query() ->where('process_request_id', $instance->getKey()) ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) diff --git a/ProcessMaker/Jobs/RunNayraScriptTask.php b/ProcessMaker/Jobs/RunNayraScriptTask.php index 2fc260dc2d..bc4fec29a5 100644 --- a/ProcessMaker/Jobs/RunNayraScriptTask.php +++ b/ProcessMaker/Jobs/RunNayraScriptTask.php @@ -12,7 +12,6 @@ use ProcessMaker\Exception\ScriptException; use ProcessMaker\Facades\WorkflowManager; use ProcessMaker\Managers\DataManager; -use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptExecutor; @@ -100,27 +99,10 @@ public function handle() $response = $script->runScript($data, $configuration, $token->getId(), $errorHandling->timeout()); - if (ProcessRequest::query()->whereKey($instance->getKey())->value('status') === 'CANCELED') { - Log::info('Skipping script task completion because the request was canceled.', [ - 'process_request_id' => $instance->getKey(), - 'token_id' => $token->getKey(), - ]); - - return; - } - // Dispatch complete task action WorkflowManager::completeTask($processModel, $instance, $token, $response['output']); } catch (ConfigurationException $exception) { $output = $exception->getMessageForData($token); - if (ProcessRequest::query()->whereKey($instance->getKey())->value('status') === 'CANCELED') { - Log::info('Skipping script task completion because the request was canceled.', [ - 'process_request_id' => $instance->getKey(), - 'token_id' => $token->getKey(), - ]); - - return; - } WorkflowManager::completeTask($processModel, $instance, $token, $output); } catch (Throwable $exception) { Log::error('Script failed: ' . $scriptRef . ' - ' . $exception->getMessage()); diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php index 43f98c4087..0f757aa7e2 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php @@ -2,7 +2,6 @@ namespace ProcessMaker\Nayra\Managers; -use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; @@ -72,27 +71,9 @@ public function completeTask(Definitions $definitions, ExecutionInstanceInterfac //Validate data $element = $token->getDefinition(true); $this->validateData($data, $definitions, $element); - $this->assertProcessRequestNotCanceled($instance); CompleteActivity::dispatchSync($definitions, $instance, $token, $data); } - /** - * Block task completion when the request was canceled (FOUR-28073). - * Used by the default manager and by WorkflowManagerRabbitMq / Kafka. - */ - protected function assertProcessRequestNotCanceled(ExecutionInstanceInterface $instance): void - { - if (!$instance instanceof ProcessRequest) { - return; - } - $status = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); - if ($status === 'CANCELED') { - throw new HttpResponseException(response()->json([ - 'message' => __('This request has been canceled. The task cannot be completed.'), - ], 422)); - } - } - /** * Fail a task. * diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php b/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php index baaa8d13a8..d66d45d5aa 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerRabbitMq.php @@ -136,7 +136,6 @@ public function completeTask(Definitions $definitions, ExecutionInstanceInterfac // Validate data $element = $token->getDefinition(true); $this->validateData($data, $definitions, $element); - $this->assertProcessRequestNotCanceled($instance); // Get complementary information $version = $instance->process_version_id; @@ -382,12 +381,6 @@ public function handleServiceTask(ProcessRequestToken $token, RunNayraServiceTas private function dispatchActionForServiceTask($version, $token, $response, $state, $userId) { - $instance = $token->processRequest - ?? ProcessRequest::query()->whereKey($token->process_request_id)->first(); - if ($instance) { - $this->assertProcessRequestNotCanceled($instance); - } - $this->dispatchAction([ 'bpmn' => $version, 'action' => self::ACTION_COMPLETE_TASK, diff --git a/ProcessMaker/Repositories/ExecutionInstanceRepository.php b/ProcessMaker/Repositories/ExecutionInstanceRepository.php index 01666f5856..90212e373a 100644 --- a/ProcessMaker/Repositories/ExecutionInstanceRepository.php +++ b/ProcessMaker/Repositories/ExecutionInstanceRepository.php @@ -260,11 +260,6 @@ public function persistInstanceCompleted(ExecutionInstanceInterface $instance) return; } - $currentStatus = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); - if ($currentStatus === 'CANCELED') { - return; - } - // Save completed instance $instance->status = 'COMPLETED'; $instance->completed_at = Carbon::now(); diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index dfb39d0bd9..77ad20fba9 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -90,27 +90,6 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter if ($process->isNonPersistent()) { return; } - $instance = $token->getInstance(); - if ($instance instanceof ProcessRequest) { - $currentStatus = ProcessRequest::query()->whereKey($instance->getKey())->value('status'); - if ($currentStatus === 'CANCELED') { - $token->status = ActivityInterface::TOKEN_STATE_CLOSED; - $token->element_id = $activity->getId(); - $token->element_type = $this->getActivityType($activity); - $token->element_name = $activity->getName(); - $token->process_id = $instance->process_id; - $token->process_request_id = $instance->getKey(); - $token->user_id = null; - $token->due_at = null; - $token->riskchanges_at = null; - $token->completed_at = Carbon::now(); - $token->updateTokenProperties(); - $token->saveOrFail(); - $token->setId($token->getKey()); - - return; - } - } $token->status = ActivityInterface::TOKEN_STATE_ACTIVE; $token->element_id = $activity->getId(); $token->element_type = $this->getActivityType($activity); From e2df15ed0ff393b5a2c744710d575892ba6d2987 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 6 May 2026 16:51:28 -0400 Subject: [PATCH 6/6] Added PHPUnit and small improvements. --- .../Api/ProcessRequestController.php | 5 +- tests/Feature/Api/ProcessRequestsTest.php | 77 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index 562ba325d0..54d50f1f1f 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php @@ -32,6 +32,7 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\User; +use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Nayra\Contracts\Bpmn\CatchEventInterface; use ProcessMaker\Notifications\ProcessCanceledNotification; use ProcessMaker\Query\SyntaxError; @@ -613,9 +614,9 @@ private function cancelRequestToken(ProcessRequest $request) // Close any token still open after status is CANCELED (race: task submit commits after CancelRequest job). ProcessRequestToken::query() ->where('process_request_id', $request->getKey()) - ->where('status', '!=', 'CLOSED') + ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) ->update([ - 'status' => 'CLOSED', + 'status' => ActivityInterface::TOKEN_STATE_CLOSED, 'completed_at' => now(), 'due_at' => null, 'riskchanges_at' => null, diff --git a/tests/Feature/Api/ProcessRequestsTest.php b/tests/Feature/Api/ProcessRequestsTest.php index f216acd653..930805cead 100644 --- a/tests/Feature/Api/ProcessRequestsTest.php +++ b/tests/Feature/Api/ProcessRequestsTest.php @@ -15,6 +15,7 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\User; +use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -524,6 +525,82 @@ public function testCancelRequestWithPermissions() $response->assertStatus(204); } + /** + * Canceling a request must leave no non-CLOSED tokens (bulk close in CancelRequest + controller). + */ + public function testCancelProcessRequestClosesAllActiveTokens(): void + { + $request = ProcessRequest::factory()->create([ + 'status' => 'ACTIVE', + ]); + + ProcessRequestToken::factory()->count(2)->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'status' => 'ACTIVE', + 'user_id' => $this->user->id, + 'completed_at' => null, + ]); + + $route = route('api.requests.update', [$request->id]); + $response = $this->apiCall('PUT', $route, ['status' => 'CANCELED']); + + $response->assertStatus(204); + + $request->refresh(); + $this->assertSame('CANCELED', $request->status); + + $openCount = ProcessRequestToken::query() + ->where('process_request_id', $request->id) + ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) + ->count(); + + $this->assertSame(0, $openCount); + } + + /** + * A token created on an already-canceled request (e.g. race with task completion) is closed on a second cancel. + */ + public function testCancelProcessRequestSecondSweepClosesStrayToken(): void + { + $request = ProcessRequest::factory()->create([ + 'status' => 'ACTIVE', + ]); + + ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'status' => 'ACTIVE', + 'user_id' => $this->user->id, + 'completed_at' => null, + ]); + + $route = route('api.requests.update', [$request->id]); + $this->apiCall('PUT', $route, ['status' => 'CANCELED'])->assertStatus(204); + + $request->refresh(); + $this->assertSame('CANCELED', $request->status); + + ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'status' => 'ACTIVE', + 'user_id' => $this->user->id, + 'completed_at' => null, + 'element_id' => 'stray_after_cancel', + 'element_type' => 'task', + ]); + + $this->apiCall('PUT', $route, ['status' => 'CANCELED'])->assertStatus(204); + + $openCount = ProcessRequestToken::query() + ->where('process_request_id', $request->id) + ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) + ->count(); + + $this->assertSame(0, $openCount); + } + /** * Test ability to complete a request if it has the status: ERROR */