diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 2d556c62f3..668d28cc44 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -3,6 +3,7 @@ namespace ProcessMaker\Http\Controllers\Api; use Carbon\Carbon; +use Facades\ProcessMaker\RollbackProcessRequest; use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -417,4 +418,22 @@ public function getScreen(Request $request, ProcessRequestToken $task, Screen $s // Authorized in policy return new ApiResource($screen->versionFor($task->processRequest)); } + + public function eligibleRollbackTask(Request $request, ProcessRequestToken $task) + { + $eligibleTask = RollbackProcessRequest::eligibleRollbackTask($task); + if (!$eligibleTask) { + return ['message' => __('Task can not be rolled back')]; + } + + return new Resource($eligibleTask); + } + + public function rollbackTask(Request $request, ProcessRequestToken $task) + { + $processDefinitions = $task->process->getDefinitions(); + $newTask = RollbackProcessRequest::rollback($task, $processDefinitions); + + return new Resource($newTask); + } } diff --git a/ProcessMaker/Http/Controllers/RequestController.php b/ProcessMaker/Http/Controllers/RequestController.php index 5bdbc2c01e..1a0f7277e8 100644 --- a/ProcessMaker/Http/Controllers/RequestController.php +++ b/ProcessMaker/Http/Controllers/RequestController.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Http\Controllers; +use Facades\ProcessMaker\RollbackProcessRequest; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; @@ -134,6 +135,12 @@ public function show(ProcessRequest $request, Media $mediaItems) $isProcessManager = $request->process?->manager_id === Auth::user()->id; + $eligibleRollbackTask = null; + $errorTask = RollbackProcessRequest::getErrorTask($request); + if ($errorTask) { + $eligibleRollbackTask = RollbackProcessRequest::eligibleRollbackTask($errorTask); + } + return view('requests.show', compact( 'request', 'files', @@ -145,7 +152,9 @@ public function show(ProcessRequest $request, Media $mediaItems) 'canPrintScreens', 'screenRequested', 'addons', - 'isProcessManager' + 'isProcessManager', + 'eligibleRollbackTask', + 'errorTask', )); } diff --git a/ProcessMaker/Policies/ProcessRequestTokenPolicy.php b/ProcessMaker/Policies/ProcessRequestTokenPolicy.php index 092de5c7dc..43893e3bd6 100644 --- a/ProcessMaker/Policies/ProcessRequestTokenPolicy.php +++ b/ProcessMaker/Policies/ProcessRequestTokenPolicy.php @@ -88,4 +88,18 @@ public function viewScreen(User $user, ProcessRequestToken $task, Screen $screen return true; } + + /** + * Determine if a user can rollback the process request. + * + * @param \ProcessMaker\Models\User $user + * @param \ProcessMaker\Models\ProcessRequestToken $processRequestToken + * + * @return bool + */ + public function rollback(User $user, ProcessRequestToken $task) + { + // For now, only the process manager can rollback the request + return $user->id === $task->process->managerId; + } } diff --git a/ProcessMaker/RollbackProcessRequest.php b/ProcessMaker/RollbackProcessRequest.php new file mode 100644 index 0000000000..a0ac68b9dc --- /dev/null +++ b/ProcessMaker/RollbackProcessRequest.php @@ -0,0 +1,206 @@ +tokens()->orderBy('id', 'desc')->first(); + + if (!$lastTask) { + return null; + } + + if ($lastTask->status === 'FAILING') { + return $lastTask; + } + + // Allow for gateway tasks + if ( + $lastTask->element_type === 'gateway' && + $lastTask->status === 'CLOSED' && + $processRequest->status === 'ERROR' + ) { + return $lastTask; + } + + return null; + } + + /** + * Find an element in the request that can be rolled-back to. + * Return null if none are eligible. + * + * @return ProcessRequestToken|null + */ + public function eligibleRollbackTask(ProcessRequestToken $currentTask) : ?ProcessRequestToken + { + $processRequest = $currentTask->processRequest; + + return $processRequest->tokens() + ->where('status', 'CLOSED') + ->where('id', '<', $currentTask->id) + ->where('element_id', '!=', $currentTask->element_id) + ->whereIn('element_type', $this->eligibleTypes) + ->orderBy('id', 'desc') + ->first(); + } + + /** + * Rollback a token to the previous token and reset the request status. + * + * @param ProcessRequestToken $task + * @return ProcessRequestToken + */ + public function rollback( + ProcessRequestToken $currentTask, + BpmnDocument $processDefinitions + ) : ProcessRequestToken { + $this->currentTask = $currentTask; + $this->processDefinitions = $processDefinitions; + $this->rollbackToTask = $this->eligibleRollbackTask($currentTask); + if (!$this->rollbackToTask) { + throw new \Exception('No eligible rollback task found'); + } + + switch ($this->rollbackToTask->element_type) { + case 'scriptTask': + $this->rollbackToScriptTask(); + break; + case 'serviceTask': + $this->rollbackToServiceTask(); + break; + case 'task': + $this->rollbackToTask(); + break; + } + + $processRequest = $this->newTask->processRequest; + $processRequest->status = 'ACTIVE'; + $processRequest->process_version_id = $processRequest->process->getLatestVersion()->id; + $processRequest->saveOrFail(); + + $currentTask->status = 'CLOSED'; + $currentTask->saveOrFail(); + + return $this->newTask; + } + + private function rollbackToTask() + { + $this->copyTask(); + + if ($this->newTask->user_id) { + $this->newTask->sendActivityActivatedNotifications(); + } + event(new ActivityAssigned($this->newTask)); + + $this->addComment(); + } + + private function rollbackToScriptTask() + { + $this->copyTask(); + + $bpmnTask = $this->processDefinitions->getEvent($this->newTask->element_id); + + WorkflowManager::runScripTask($bpmnTask, $this->newTask); + + $this->addComment(); + } + + private function rollbackToServiceTask() + { + $this->copyTask(); + + $bpmnTask = $this->processDefinitions->getEvent($this->newTask->element_id); + + WorkflowManager::runServiceTask($bpmnTask, $this->newTask); + + $this->addComment(); + } + + private function copyTask() + { + $newTask = $this->rollbackToTask->replicate(); + $newTask->uuid = null; + $newTask->status = 'ACTIVE'; + $newTask->saveOrFail(); + $this->newTask = $newTask; + } + + /** + * Add a comment that the task was rolled back. + * + * @return void + */ + private function addComment() : void + { + $user = Auth::user(); + $userName = $user ? $user->fullname : __('The System'); + Comment::create([ + 'type' => 'LOG', + 'user_id' => $user ? $user->id : null, + 'commentable_type' => ProcessRequest::class, + 'commentable_id' => $this->currentTask->process_request_id, + 'subject' => 'Rollback', + 'body' => __(':user rolled back :failed_task_name to :new_task_name', [ + 'user' => $userName, + 'failed_task_name' => $this->currentTask->element_name, + 'new_task_name' => $this->newTask->element_name, + ]), + ]); + } +} diff --git a/resources/js/components/Timeline.vue b/resources/js/components/Timeline.vue index b92ff7d09b..2451ac3595 100644 --- a/resources/js/components/Timeline.vue +++ b/resources/js/components/Timeline.vue @@ -30,6 +30,7 @@ const SubjectIcons = { 'Task Complete': 'far fa-square', 'Gateway': 'far fa-square fa-rotate-45', + 'Rollback': 'fa fa-undo', }; export default { props: ["commentable_id", diff --git a/resources/lang/en.json b/resources/lang/en.json index a6578f83ab..f1d628028b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1773,5 +1773,11 @@ "Seconds to wait before retrying. Leave empty to use script default. Set to 0 for no retry wait time. This setting is only used when running a script task in a process.": "Seconds to wait before retrying. Leave empty to use script default. Set to 0 for no retry wait time. This setting is only used when running a script task in a process.", "Number of times to retry. Leave empty to use script default. Set to 0 for no retry attempts. This setting is only used when running a script task in a process.": "Number of times to retry. Leave empty to use script default. Set to 0 for no retry attempts. This setting is only used when running a script task in a process.", "View Request": "View Request", - "Execution Error": "Execution Error" + "Execution Error": "Execution Error", + "Task can not be rolled back": "Task can not be rolled back", + ":user rolled back :failed_task_name to :new_task_name": ":user rolled back :failed_task_name to :new_task_name", + "Rollback Request": "Rollback Request", + "Rollback": "Rollback", + "Rollback to task" : "Rollback to task", + "Are you sure you want to rollback to the task @{{name}}? Warning! This request will continue as the current published process version.": "Are you sure you want to rollback to the task @{{name}}? Warning! This request will continue as the current published process version." } diff --git a/resources/views/requests/show.blade.php b/resources/views/requests/show.blade.php index 196a4456cf..ada5a7fd0a 100644 --- a/resources/views/requests/show.blade.php +++ b/resources/views/requests/show.blade.php @@ -313,6 +313,18 @@ class="d-inline-flex pull-left align-items-center" :input-data="requestBy" displ @endif + @if ($eligibleRollbackTask) + @can('rollback', $errorTask) +
  • +
    {{ __('Rollback Request') }}
    + + {{ __('Rollback to task') }}: {{ $eligibleRollbackTask->element_name }} ({{ $eligibleRollbackTask->element_id }}) +
  • + @endcan + @endif @if ($request->parentRequest)
  • {{ __('Parent Request') }}
    @@ -701,6 +713,18 @@ classStatusCard() { apiRequest ); }, + rollback(errorTaskId, rollbackToName) { + ProcessMaker.confirmModal( + this.$t('Confirm'), + this.$t('Are you sure you want to rollback to the task @{{name}}? Warning! This request will continue as the current published process version.', { name: rollbackToName }), + 'default', + () => { + ProcessMaker.apiClient.post(`tasks/${errorTaskId}/rollback`).then(response => { + location.reload(); + }); + } + ) + }, getConfigurationComments() { if (this.canViewComments) { const commentsPackage = 'comment-editor' in Vue.options.components; diff --git a/routes/api.php b/routes/api.php index 40bda4153a..3a112d3d95 100644 --- a/routes/api.php +++ b/routes/api.php @@ -149,6 +149,8 @@ Route::get('tasks', [TaskController::class, 'index'])->name('tasks.index'); // Already filtered in controller Route::get('tasks/{task}', [TaskController::class, 'show'])->name('tasks.show')->middleware('can:view,task'); Route::get('tasks/{task}/screens/{screen}', [TaskController::class, 'getScreen'])->name('tasks.get_screen')->middleware('can:viewScreen,task,screen'); + Route::get('tasks/{task}/eligibleRollbackTask', [TaskController::class, 'eligibleRollbackTask'])->name('tasks.eligible_rollback_task')->middleware('can:rollback,task'); + Route::post('tasks/{task}/rollback', [TaskController::class, 'rollbackTask'])->name('tasks.rollback_task')->middleware('can:rollback,task'); // Requests Route::get('requests', [ProcessRequestController::class, 'index'])->name('requests.index'); // Already filtered in controller diff --git a/tests/Feature/Api/TasksTest.php b/tests/Feature/Api/TasksTest.php index 8e3ad2fa09..d9a5ff28cb 100644 --- a/tests/Feature/Api/TasksTest.php +++ b/tests/Feature/Api/TasksTest.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Database\Seeders\PermissionSeeder; +use Facades\ProcessMaker\RollbackProcessRequest; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Support\Facades\Notification; use ProcessMaker\Facades\WorkflowManager; @@ -14,6 +15,7 @@ use ProcessMaker\Models\ProcessNotificationSetting; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\ProcessTaskAssignment; use ProcessMaker\Models\Screen; use ProcessMaker\Models\User; use ProcessMaker\Notifications\ActivityActivatedNotification; @@ -705,4 +707,50 @@ public function testSelfServeNotifications() Notification::assertSentTo([$this->user], ActivityActivatedNotification::class); } + + public function testRollback() + { + $bpmn = file_get_contents(__DIR__ . '/../../Fixtures/rollback_test.bpmn'); + $bpmn = str_replace('[task_user_id]', $this->user->id, $bpmn); + $process = Process::factory()->create([ + 'bpmn' => $bpmn, + ]); + ProcessTaskAssignment::factory()->create([ + 'process_id' => $process->id, + 'process_task_id' => 'node_255', + 'assignment_id' => $this->user->id, + 'assignment_type' => 'ProcessMaker\Models\User', + ]); + $definitions = $process->getDefinitions(); + $startEvent = $definitions->getEvent('node_1'); + $request = WorkflowManager::triggerStartEvent($process, $startEvent, []); + + $formTask = $request->tokens()->where('element_id', 'node_255')->firstOrFail(); + + // Next task should fail because the rule expression variable 'foo' does not exist + WorkflowManager::completeTask($process, $request, $formTask, ['someValue' => 123]); + + $this->assertEquals('ERROR', $request->refresh()->status); + + $errorTask = RollbackProcessRequest::getErrorTask($request); + $processDefinitions = $process->getDefinitions(); + $newTask = RollbackProcessRequest::rollback($errorTask, $processDefinitions); + + $this->assertEquals('ACTIVE', $request->refresh()->status); + $this->assertEquals($newTask->id, $request->tokens()->where('status', 'ACTIVE')->first()->id); + + // Set data to make the rule expression pass + WorkflowManager::completeTask($process, $request, $newTask, ['foo' => 123]); + + // Now we have a valid task we can use to complete the request + $ruleExpressionTask = $request->refresh()->tokens() + ->where('element_id', 'node_272') + ->where('status', 'ACTIVE') + ->firstOrFail(); + + WorkflowManager::completeTask($process, $request, $ruleExpressionTask, []); + + $this->assertEquals('CLOSED', $newTask->refresh()->status); + $this->assertEquals('COMPLETED', $request->refresh()->status); + } } diff --git a/tests/Fixtures/rollback_test.bpmn b/tests/Fixtures/rollback_test.bpmn new file mode 100644 index 0000000000..78bbc79c1a --- /dev/null +++ b/tests/Fixtures/rollback_test.bpmn @@ -0,0 +1,50 @@ + + + + + node_270 + + + node_283 + + + node_270 + node_281 + + + + node_281 + node_283 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/unit/ProcessMaker/RollbackProcessRequestTest.php b/tests/unit/ProcessMaker/RollbackProcessRequestTest.php new file mode 100644 index 0000000000..754ee5307a --- /dev/null +++ b/tests/unit/ProcessMaker/RollbackProcessRequestTest.php @@ -0,0 +1,114 @@ +processRequest = ProcessRequest::factory()->create(['status' => 'ERROR']); + $this->rollbackToTask = ProcessRequestToken::factory()->create([ + 'status' => 'CLOSED', + 'process_request_id' => $this->processRequest->id, + 'element_id' => 'node_5', + 'element_type' => $rollbackToType, + ]); + ProcessRequestToken::factory()->create([ + 'status' => 'CLOSED', + 'process_request_id' => $this->processRequest->id, + 'element_id' => 'node_6', + 'element_type' => 'gateway', + ]); + $task = ProcessRequestToken::factory()->create([ + 'status' => 'FAILING', + 'process_request_id' => $this->processRequest->id, + 'element_id' => 'node_7', + 'element_type' => 'scriptTask', + ]); + + return $task; + } + + public function testRollbackToFormTask() + { + $task = $this->createTasks('task'); + + $mockProcessDefinitions = Mockery::mock(BpmnDocument::class); + $newTask = RollbackProcessRequest::rollback($task, $mockProcessDefinitions); + $this->assertEquals('node_5', $newTask->element_id); + $this->assertEquals('ACTIVE', $newTask->status); + + $comment = Comment::orderBy('id', 'desc')->first(); + $this->assertEquals($comment->body, "The System rolled back {$task->element_name} to {$newTask->element_name}"); + } + + public function testRollbackToScriptTask() + { + $task = $this->createTasks('scriptTask'); + $mockProcessDefinitions = $this->mockRunScriptTask(); + + $newTask = RollbackProcessRequest::rollback($task, $mockProcessDefinitions); + $this->assertEquals('node_5', $newTask->element_id); + $this->assertEquals('ACTIVE', $newTask->status); + } + + public function testRollbackToServiceTask() + { + $task = $this->createTasks('serviceTask'); + $mockProcessDefinitions = $this->mockRunServiceTask(); + + $newTask = RollbackProcessRequest::rollback($task, $mockProcessDefinitions); + $this->assertEquals('node_5', $newTask->element_id); + $this->assertEquals('ACTIVE', $newTask->status); + $this->assertEquals('ACTIVE', $this->processRequest->refresh()->status); + } + + private function mockRunScriptTask() + { + $mocksScriptTask = Mockery::mock(ScriptTaskInterface::class); + $mockProcessDefinitions = Mockery::mock(BpmnDocument::class); + $mockProcessDefinitions->shouldReceive('getEvent') + ->with('node_5') + ->andReturn($mocksScriptTask); + WorkflowManager::shouldReceive('runScripTask') + ->withArgs(function ($scriptTask, $task) use ($mocksScriptTask) { + return $scriptTask === $mocksScriptTask && $task->element_id = 'node_5'; + }); + + return $mockProcessDefinitions; + } + + private function mockRunServiceTask() + { + $mockServiceTask = Mockery::mock(ServiceTaskInterface::class); + $mockProcessDefinitions = Mockery::mock(BpmnDocument::class); + $mockProcessDefinitions->shouldReceive('getEvent') + ->with('node_5') + ->andReturn($mockServiceTask); + WorkflowManager::shouldReceive('runServiceTask') + ->withArgs(function ($serviceTask, $task) use ($mockServiceTask) { + return $serviceTask === $mockServiceTask && $task->element_id = 'node_5'; + }); + + return $mockProcessDefinitions; + } +}