diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index 6508bba81b..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; @@ -609,6 +610,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', '!=', ActivityInterface::TOKEN_STATE_CLOSED) + ->update([ + 'status' => ActivityInterface::TOKEN_STATE_CLOSED, + 'completed_at' => now(), + 'due_at' => null, + 'riskchanges_at' => null, + 'user_id' => null, + ]); + // Update case status CaseUpdateStatus::dispatchSync($request); diff --git a/ProcessMaker/Jobs/CancelRequest.php b/ProcessMaker/Jobs/CancelRequest.php index d7a829c29d..fb154344bf 100644 --- a/ProcessMaker/Jobs/CancelRequest.php +++ b/ProcessMaker/Jobs/CancelRequest.php @@ -2,9 +2,12 @@ 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\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Notifications\ProcessCanceledNotification; 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); } + + // 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) + ->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..bc4fec29a5 100644 --- a/ProcessMaker/Jobs/RunNayraScriptTask.php +++ b/ProcessMaker/Jobs/RunNayraScriptTask.php @@ -36,7 +36,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) 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 */