diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php index 334c109077..3b90cb0ca5 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php @@ -15,9 +15,16 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\ProcessTranslations\TranslationManager; +use ProcessMaker\Services\TaskCompletionRawService; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class TaskController extends Controller { + public function __construct( + private readonly TaskCompletionRawService $taskCompletionRawService, + ) { + } + protected $defaultFields = [ 'id', 'element_id', @@ -151,4 +158,26 @@ public function showInterstitial($taskId) return $response; } + + /** + * Complete a task using the raw-query optimized path. + */ + public function update(Request $request, int $taskId) + { + if ($request->input('status') !== 'COMPLETED') { + abort(422, __('Only task completion is supported on this endpoint. Use PUT /api/1.0/tasks/{id} for other updates.')); + } + + try { + $task = $this->taskCompletionRawService->completeTask( + $taskId, + json_optimize_decode($request->getContent(), true) ?: [], + $request->user(), + ); + } catch (NotFoundHttpException $exception) { + return response()->json(['message' => $exception->getMessage()], 404); + } + + return response()->json($task); + } } diff --git a/ProcessMaker/Repositories/ExecutionInstanceRepository.php b/ProcessMaker/Repositories/ExecutionInstanceRepository.php index 96b83e4236..ccad705191 100644 --- a/ProcessMaker/Repositories/ExecutionInstanceRepository.php +++ b/ProcessMaker/Repositories/ExecutionInstanceRepository.php @@ -14,6 +14,7 @@ use ProcessMaker\Nayra\Contracts\Repositories\ExecutionInstanceRepositoryInterface; use ProcessMaker\Nayra\Contracts\Repositories\StorageInterface; use ProcessMaker\Nayra\RepositoryTrait; +use ProcessMaker\Repositories\TokenPersistenceRawRepository; use ProcessMaker\SanitizeHelper; /** @@ -236,6 +237,16 @@ public function persistInstanceUpdated(ExecutionInstanceInterface $instance) return; } + if ( + config('app.token_persistence_raw_enabled', false) + && $instance instanceof ProcessRequest + ) { + app(TokenPersistenceRawRepository::class)->persistInstanceUpdated($instance); + CaseUpdateStatus::dispatchSync($instance); + + return; + } + // Save updated instance if (!$instance->status) { $instance->status = 'ACTIVE'; diff --git a/ProcessMaker/Repositories/ProcessExecutionRawRepository.php b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php new file mode 100644 index 0000000000..67fb1fb114 --- /dev/null +++ b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php @@ -0,0 +1,542 @@ +getProperty('assignment', $default); + $config = json_decode($activity->getProperty('config', '{}'), true) ?: []; + $escalateToManager = $config['escalateToManager'] ?? false; + + $definitionFlags = $this->resolveAssignmentLockAndSelfServiceFromDefinitions($token, $activity); + $assignmentLock = $definitionFlags['assignmentLock']; + $isSelfService = $definitionFlags['isSelfService']; + + $request = $token->getInstance(); + $requestId = (int) $request->getKey(); + $processId = (int) $process->getKey(); + + if ($assignmentType === 'rule_expression') { + $userByRuleId = $isSelfService ? null : $this->getNextUserByRuleRaw($process, $processId, $activity, $token); + if ($userByRuleId !== null) { + $userId = $process->scalateToManagerIfEnabled($userByRuleId, $activity, $token, $assignmentType); + + return $this->checkAssignmentRaw( + $process, + $processId, + $request, + $activity, + $assignmentType, + $escalateToManager, + $this->getUserByIdRaw($userId), + $token + ); + } + } + + if (filter_var($assignmentLock, FILTER_VALIDATE_BOOLEAN) === true) { + $userId = $this->getLastUserAssignedToTaskRaw($processId, $activity->getId(), $requestId); + if ($userId) { + return $this->checkAssignmentRaw( + $process, + $processId, + $request, + $activity, + $assignmentType, + $escalateToManager, + $this->getUserByIdRaw($userId), + $token + ); + } + } + + switch ($assignmentType) { + case 'user_group': + case 'group': + $userId = $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId()); + break; + case 'user': + $userId = $this->getNextUserAssignmentRaw($processId, $activity->getId()); + break; + case 'user_by_id': + $userId = $this->getNextUserFromVariableRaw($activity, $token); + break; + case 'process_variable': + $userId = $this->getNextUserFromProcessVariableRaw($process, $processId, $activity, $token); + break; + case 'requester': + $userId = $this->getRequesterUserIdRaw($activity, $token); + break; + case 'previous_task_assignee': + $userId = $this->previousTaskAssignee()->getNextUser($activity, $token, $process, $request); + break; + case 'process_manager': + $userId = $this->processManagerAssigned()->getNextUser($activity, $token, $process, $request); + break; + case 'manual': + case 'self_service': + $userId = null; + break; + case 'script': + default: + $userId = null; + } + + if ($isSelfService && in_array($assignmentType, ['user_group', 'process_variable', 'rule_expression'], true)) { + $userId = null; + } + + $userId = $process->scalateToManagerIfEnabled($userId, $activity, $token, $assignmentType); + + return $this->checkAssignmentRaw( + $process, + $processId, + $request, + $activity, + $assignmentType, + $escalateToManager, + $this->getUserByIdRaw($userId), + $token + ); + } + + public function taskHasDraftRaw(int $taskId): bool + { + return (bool) DB::selectOne( + 'SELECT 1 AS found FROM task_drafts WHERE task_id = ? LIMIT 1', + [$taskId] + ); + } + + /** + * Hydrate an Eloquent model from a raw DB row. + * + * @template T of Model + * + * @param class-string $modelClass + * @return T + */ + public function hydrateModelFromRowRaw(string $modelClass, object $row): Model + { + /** @var Model $model */ + $model = new $modelClass(); + $model->setRawAttributes((array) $row, true); + $model->exists = true; + $model->syncOriginal(); + + return $model; + } + + /** + * Analog to checkAssignment(), reusing a User already loaded via getUserByIdRaw(). + */ + private function checkAssignmentRaw( + Process $process, + int $processId, + ProcessRequest $request, + ActivityInterface $activity, + $assignmentType, + $escalateToManager, + ?User $user = null, + ?ProcessRequestToken $token = null + ): ?User { + $config = $activity->getProperty('config') ? json_decode($activity->getProperty('config'), true) : []; + $selfServiceToggle = array_key_exists('selfService', $config ?? []) ? $config['selfService'] : false; + $isSelfService = $selfServiceToggle || $assignmentType === 'self_service'; + + if ($activity instanceof ScriptTaskInterface + || $activity instanceof ServiceTaskInterface) { + return $user; + } + if ($user === null) { + if ($isSelfService && !$escalateToManager) { + return null; + } + if ($token === null) { + throw new ThereIsNoProcessManagerAssignedException($activity); + } + $userId = $this->processManagerAssigned()->getNextUser($activity, $token, $process, $request); + if (!$userId) { + throw new ThereIsNoProcessManagerAssignedException($activity); + } + $user = $this->getUserByIdRaw($userId); + } + + return $user; + } + + /** + * Match Process::getNextUser() — assignmentLock and selfService from version BPMN element properties. + */ + private function resolveAssignmentLockAndSelfServiceFromDefinitions( + ProcessRequestToken $token, + ActivityInterface $activity + ): array { + $definitions = $token->getInstance()->getVersionDefinitions(); + $element = $definitions->findElementById($activity->getId()); + $properties = $element?->getBpmnElementInstance()?->getProperties() ?? []; + + $assignmentLock = array_key_exists('assignmentLock', $properties) ? $properties['assignmentLock'] : false; + $config = array_key_exists('config', $properties) ? json_decode($properties['config'], true) : []; + $isSelfService = array_key_exists('selfService', $config ?? []) ? $config['selfService'] : false; + + return [ + 'assignmentLock' => $assignmentLock, + 'isSelfService' => (bool) $isSelfService, + ]; + } + + private function previousTaskAssignee(): PreviousTaskAssignee + { + return $this->previousTaskAssignee ??= new PreviousTaskAssignee(); + } + + private function processManagerAssigned(): ProcessManagerAssigned + { + return $this->processManagerAssigned ??= new ProcessManagerAssigned(); + } + + /** + * Analog to User::find() — single flat query, no eager loads. + */ + private function getUserByIdRaw(?int $userId): ?User + { + if (!$userId) { + return null; + } + + $row = DB::selectOne('SELECT * FROM users WHERE id = ? LIMIT 1', [$userId]); + if (!$row) { + return null; + } + + return $this->hydrateModelFromRowRaw(User::class, $row); + } + + private function getRequesterUserIdRaw($activity, ProcessRequestToken $token): ?int + { + $processRequest = $token->getInstance(); + + if ($activity instanceof Activity && !$processRequest->user_id) { + throw new TaskDoesNotHaveRequesterException(); + } + + return $processRequest->user_id ? (int) $processRequest->user_id : null; + } + + private function getLastUserAssignedToTaskRaw(int $processId, string $processTaskUuid, int $processRequestId): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? AND process_request_id = ? ORDER BY created_at DESC LIMIT 1', + [$processId, $processTaskUuid, $processRequestId] + ); + + return $row && $row->user_id ? (int) $row->user_id : null; + } + + private function getNextUserFromGroupAssignmentRaw(int $processId, string $processTaskUuid, ?array $users = null): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? ORDER BY created_at DESC, id DESC LIMIT 1', + [$processId, $processTaskUuid] + ); + if ($users === null) { + $users = $this->getAssignableUserIdsRaw($processId, $processTaskUuid); + } + if (empty($users)) { + return null; + } + sort($users); + $lastUserId = $row && $row->user_id ? (int) $row->user_id : null; + if ($lastUserId) { + foreach ($users as $user) { + if ($user > $lastUserId) { + return (int) $user; + } + } + } + + return (int) $users[0]; + } + + private function getNextUserAssignmentRaw(int $processId, string $processTaskUuid, ?array $users = null): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? ORDER BY created_at DESC LIMIT 1', + [$processId, $processTaskUuid] + ); + if ($users === null) { + $users = $this->getAssignableUserIdsRaw($processId, $processTaskUuid); + } + if (empty($users)) { + return null; + } + sort($users); + $lastUserId = $row && $row->user_id ? (int) $row->user_id : null; + if ($lastUserId) { + foreach ($users as $user) { + if ($user > $lastUserId) { + return (int) $user; + } + } + } + + return (int) $users[0]; + } + + private function getAssignableUserIdsRaw(int $processId, string $processTaskUuid): array + { + $assignments = DB::select( + 'SELECT assignment_id, assignment_type FROM process_task_assignments WHERE process_id = ? AND process_task_id = ?', + [$processId, $processTaskUuid] + ); + + $users = []; + $groupIds = []; + foreach ($assignments as $assignment) { + if ($assignment->assignment_type === User::class) { + $users[(int) $assignment->assignment_id] = (int) $assignment->assignment_id; + } else { + $groupIds[] = (int) $assignment->assignment_id; + } + } + + if ($groupIds) { + $this->mergeGroupMemberUserIdsRaw($groupIds, $users); + } + + return array_values($users); + } + + private function mergeGroupMemberUserIdsRaw(array $groupIds, array &$users): void + { + $pending = array_values(array_unique(array_map('intval', $groupIds))); + $visitedGroups = []; + + while ($pending) { + $batch = array_values(array_diff($pending, $visitedGroups)); + if (empty($batch)) { + break; + } + $visitedGroups = array_merge($visitedGroups, $batch); + $pending = []; + $placeholders = implode(',', array_fill(0, count($batch), '?')); + + $members = DB::select( + "SELECT member_id, member_type FROM group_members WHERE group_id IN ($placeholders)", + $batch + ); + + $subGroupIds = []; + foreach ($members as $member) { + if ($member->member_type === User::class) { + $users[(int) $member->member_id] = (int) $member->member_id; + } elseif ($member->member_type === Group::class) { + $subGroupIds[] = (int) $member->member_id; + } + } + + if ($subGroupIds) { + $subGroupIds = array_values(array_unique($subGroupIds)); + $groupPlaceholders = implode(',', array_fill(0, count($subGroupIds), '?')); + $activeGroups = DB::select( + "SELECT id FROM groups WHERE id IN ($groupPlaceholders) AND status = ?", + array_merge($subGroupIds, ['ACTIVE']) + ); + foreach ($activeGroups as $group) { + $pending[] = (int) $group->id; + } + } + } + + if (empty($users)) { + return; + } + + $userIds = array_keys($users); + $userPlaceholders = implode(',', array_fill(0, count($userIds), '?')); + $statusPlaceholders = implode(',', array_fill(0, count(Process::NOT_ASSIGNABLE_USER_STATUS), '?')); + $activeRows = DB::select( + "SELECT id FROM users WHERE id IN ($userPlaceholders) AND status NOT IN ($statusPlaceholders)", + array_merge($userIds, Process::NOT_ASSIGNABLE_USER_STATUS) + ); + $activeIds = array_flip(array_map(fn ($row) => (int) $row->id, $activeRows)); + $users = array_intersect_key($users, $activeIds); + } + + private function getNextUserFromVariableRaw($activity, ProcessRequestToken $token): ?int + { + try { + $userExpression = $activity->getProperty('assignedUsers'); + $dataManager = new DataManager(); + $instanceData = $dataManager->getData($token); + $mustache = new Mustache_Engine(); + $userId = (int) $mustache->render($userExpression, $instanceData); + if (!$this->getUserByIdRaw($userId)) { + throw new InvalidUserAssignmentException($userExpression, $userId); + } + + return $userId; + } catch (Exception $exception) { + return null; + } + } + + private function getNextUserFromProcessVariableRaw( + Process $process, + int $processId, + $activity, + ProcessRequestToken $token + ): ?int { + if ($token->getSelfServiceAttribute()) { + return null; + } + + $usersVariable = $activity->getProperty('assignedUsers'); + $groupsVariable = $activity->getProperty('assignedGroups'); + $dataManager = new DataManager(); + $instanceData = $dataManager->getData($token); + + $assignedUsers = $usersVariable ? feelExpression($usersVariable, $instanceData) : []; + $assignedGroups = $groupsVariable ? feelExpression($groupsVariable, $instanceData) : []; + + if (!is_array($assignedUsers)) { + $assignedUsers = [$assignedUsers]; + } + if (!is_array($assignedGroups)) { + $assignedGroups = [$assignedGroups]; + } + + $users = []; + if ($assignedUsers) { + $uniqueUsers = array_values(array_unique(array_map('intval', $assignedUsers))); + $placeholders = implode(',', array_fill(0, count($uniqueUsers), '?')); + $statusPlaceholders = implode(',', array_fill(0, count(Process::NOT_ASSIGNABLE_USER_STATUS), '?')); + $activeRows = DB::select( + "SELECT id FROM users WHERE id IN ($placeholders) AND status NOT IN ($statusPlaceholders)", + array_merge($uniqueUsers, Process::NOT_ASSIGNABLE_USER_STATUS) + ); + foreach ($activeRows as $row) { + $users[(int) $row->id] = (int) $row->id; + } + + $oooPlaceholders = implode(',', array_fill(0, count($uniqueUsers), '?')); + $oooRows = DB::select( + "SELECT delegation_user_id FROM users WHERE id IN ($oooPlaceholders) AND status = ? AND delegation_user_id IS NOT NULL", + array_merge($uniqueUsers, ['OUT_OF_OFFICE']) + ); + foreach ($oooRows as $row) { + $users[(int) $row->delegation_user_id] = (int) $row->delegation_user_id; + } + } + + foreach ($assignedGroups as $groupId) { + $this->mergeGroupMemberUserIdsRaw([(int) $groupId], $users); + } + + return $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId(), array_values($users)); + } + + private function getNextUserByRuleRaw( + Process $process, + int $processId, + $activity, + ProcessRequestToken $token + ): ?int { + $assignmentRules = $activity->getProperty('assignmentRules', null); + $instanceData = $token->getInstance()->getDataStore()->getData(); + + if (!$assignmentRules || !$instanceData) { + return null; + } + + $list = json_decode($assignmentRules); + $list = ($list === null) ? [] : $list; + foreach ($list as $item) { + $formalExp = new FormalExpression(); + $formalExp->setLanguage('FEEL'); + $formalExp->setBody($item->expression); + if (!$formalExp($instanceData)) { + continue; + } + + switch ($item->type) { + case 'user_group': + $users = []; + foreach ($item->assignee->users as $user) { + $users[$user] = $user; + } + foreach ($item->assignee->groups as $group) { + $this->mergeGroupMemberUserIdsRaw([(int) $group], $users); + } + $userId = $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId(), array_values($users)); + break; + case 'group': + $users = []; + $this->mergeGroupMemberUserIdsRaw([(int) $item->assignee], $users); + $userId = $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId(), array_values($users)); + break; + case 'user': + $userId = (int) $item->assignee; + break; + case 'requester': + $userId = $this->getRequesterUserIdRaw($activity, $token); + break; + case 'manual': + case 'self_service': + $userId = null; + break; + case 'user_by_id': + $mustache = new Mustache_Engine(); + $userId = (int) $mustache->render($item->assignee, $instanceData); + break; + case 'script': + default: + $userId = null; + } + + if (!$userId) { + return null; + } + + return $this->getUserByIdRaw((int) $userId)?->getKey(); + } + + return null; + } +} diff --git a/ProcessMaker/Repositories/TaskCompletionRawRepository.php b/ProcessMaker/Repositories/TaskCompletionRawRepository.php new file mode 100644 index 0000000000..8913415302 --- /dev/null +++ b/ProcessMaker/Repositories/TaskCompletionRawRepository.php @@ -0,0 +1,160 @@ +is_self_service = (bool) $row->is_self_service; + $row->self_service_groups = $this->decodeJson($row->self_service_groups); + + return $row; + } + + public function findProcessForComplete(int $processId): ?stdClass + { + $row = DB::selectOne( + 'SELECT id, bpmn, start_events, properties, status, name, process_category_id + FROM processes + WHERE id = ? AND deleted_at IS NULL + LIMIT 1', + [$processId] + ); + + if ($row === null) { + return null; + } + + $row->properties = $this->decodeJson($row->properties) ?? []; + $row->manager_id = $this->decodeManagerIds($row->properties['manager_id'] ?? null); + $row->start_events = $this->decodeJson($row->start_events); + + return $row; + } + + public function findProcessRequestForComplete(int $processRequestId): ?stdClass + { + $row = DB::selectOne( + 'SELECT id, process_id, process_version_id, status, do_not_sanitize, user_id, + parent_request_id, process_collaboration_id + FROM process_requests + WHERE id = ? + LIMIT 1', + [$processRequestId] + ); + + if ($row === null) { + return null; + } + + $row->do_not_sanitize = $this->decodeJson($row->do_not_sanitize) ?? []; + + return $row; + } + + public function findProcessVersionForComplete(?int $processVersionId): ?stdClass + { + if ($processVersionId === null) { + return null; + } + + $row = DB::selectOne( + 'SELECT id, process_id, bpmn, start_events, status, name, alternative + FROM process_versions + WHERE id = ? + LIMIT 1', + [$processVersionId] + ); + + if ($row === null) { + return null; + } + + $row->start_events = $this->decodeJson($row->start_events); + + return $row; + } + + public function taskHasDraft(int $taskId): bool + { + return $this->executionRawRepository->taskHasDraftRaw($taskId); + } + + public function findTaskForResponse(int $taskId): ?stdClass + { + return DB::selectOne( + 'SELECT id, element_name, element_id, element_type, status, due_at, process_request_id, + user_id, process_id, is_self_service, self_service_groups, token_properties, + created_at, updated_at, completed_at + FROM process_request_tokens + WHERE id = ? + LIMIT 1', + [$taskId] + ); + } + + /** + * @return list + */ + private function decodeManagerIds(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_array($value)) { + return array_map('intval', $value); + } + + if (is_numeric($value)) { + return [(int) $value]; + } + + $decoded = $this->decodeJson($value); + + if (is_array($decoded)) { + return array_map('intval', $decoded); + } + + return []; + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode((string) $value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } +} diff --git a/ProcessMaker/Repositories/TokenPersistenceRawRepository.php b/ProcessMaker/Repositories/TokenPersistenceRawRepository.php new file mode 100644 index 0000000000..726b7ff2f8 --- /dev/null +++ b/ProcessMaker/Repositories/TokenPersistenceRawRepository.php @@ -0,0 +1,162 @@ +updateToken($token, [ + 'status', + 'element_id', + 'element_type', + 'element_name', + 'process_id', + 'process_request_id', + 'user_id', + 'is_self_service', + 'self_service_groups', + 'due_at', + 'initiated_at', + 'riskchanges_at', + 'token_properties', + 'stage_id', + 'stage_name', + ]); + } + + /** + * Persist token fields after persistActivityCompleted. + */ + public function saveCompletedToken(ProcessRequestToken $token): void + { + $this->updateToken($token, [ + 'status', + 'element_id', + 'process_request_id', + 'completed_at', + 'token_properties', + ]); + } + + /** + * Persist token fields after persistActivityClosed. + */ + public function saveClosedToken(ProcessRequestToken $token): void + { + $this->updateToken($token, [ + 'status', + 'element_id', + 'element_type', + 'element_name', + 'process_id', + 'process_request_id', + 'data', + 'token_properties', + ]); + } + + /** + * Analog to ExecutionInstanceRepository::persistInstanceUpdated without Eloquent save. + */ + public function persistInstanceUpdated(ProcessRequest $instance): void + { + $store = $instance->getDataStore(); + $row = DB::selectOne( + 'SELECT data, execution_revision FROM process_requests WHERE id = ? LIMIT 1', + [$instance->getKey()] + ); + + if (!$instance->status) { + $instance->status = 'ACTIVE'; + } + + $storedData = $row && $row->data ? json_decode((string) $row->data, true) : []; + $mergedData = $store->updateArray(is_array($storedData) ? $storedData : []); + $newRevision = (int) ($row->execution_revision ?? 0) + 1; + + $instance->data = $mergedData; + $instance->execution_revision = $newRevision; + + $payload = [ + 'data' => json_encode($mergedData), + 'execution_revision' => $newRevision, + 'updated_at' => Carbon::now(), + ]; + + foreach (['status', 'last_stage_id', 'last_stage_name', 'progress', 'completed_at'] as $field) { + if (array_key_exists($field, $instance->getDirty())) { + $payload[$field] = $instance->getAttributes()[$field]; + } + } + + $this->runUpdate('process_requests', (int) $instance->getKey(), $payload); + $instance->syncChanges(); + } + + /** + * @param list $fields + */ + private function updateToken(ProcessRequestToken $token, array $fields): void + { + $payload = []; + foreach ($fields as $field) { + if (!array_key_exists($field, $token->getAttributes())) { + continue; + } + $payload[$field] = $this->serializeColumnValue($field, $token->getAttributes()[$field]); + } + + $payload['updated_at'] = Carbon::now(); + + $tokenId = (int) $token->getKey(); + if ($tokenId <= 0) { + $token->saveOrFail(); + + return; + } + + $this->runUpdate('process_request_tokens', $tokenId, $payload); + $token->syncChanges(); + } + + /** + * @param array $payload + */ + private function runUpdate(string $table, int $id, array $payload): void + { + if ($payload === []) { + return; + } + + $columns = array_keys($payload); + $assignments = implode(', ', array_map(static fn (string $column): string => "`{$column}` = ?", $columns)); + $values = array_values($payload); + $values[] = $id; + + DB::update("UPDATE `{$table}` SET {$assignments} WHERE `id` = ?", $values); + } + + private function serializeColumnValue(string $field, mixed $value): mixed + { + if (in_array($field, ['self_service_groups', 'token_properties', 'data'], true)) { + return $value === null ? null : json_encode($value); + } + + if ($value instanceof Carbon) { + return $value->format('Y-m-d H:i:s'); + } + + return $value; + } +} diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index f7a07913c0..7524fb227d 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -100,7 +100,7 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter if ($isScriptOrServiceTask) { $user = null; } else { - $user = $token->getInstance()->getProcess()->getOwnerDocument()->getModel()->getNextUser($activity, $token); + $user = $this->resolveNextUser($activity, $token); } $this->addUserToData($token->getInstance(), $user); $this->addRequestToData($token->getInstance()); @@ -166,7 +166,7 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $token->riskchanges_at = $due ? Carbon::now()->addHours($due * 0.7) : null; $token->updateTokenProperties(); $token->getInstance()->updateCatchEvents(); - $token->saveOrFail(); + $this->saveToken($token); $token->setId($token->getKey()); $request = $token->getInstance(); $request->last_stage_id = $token->stage_id; @@ -349,7 +349,7 @@ public function persistActivityCompleted(ActivityInterface $activity, TokenInter $token->process_request_id = $token->getInstance()->getKey(); $token->completed_at = Carbon::now(); $token->updateTokenProperties(); - $token->save(); + $this->saveToken($token, 'completed'); $token->setId($token->getKey()); $this->updateCaseStartedTask($token); @@ -383,7 +383,7 @@ public function persistActivityClosed(ActivityInterface $activity, TokenInterfac $token->process_request_id = $token->getInstance()->getKey(); $token->data = $token->getInstance()->getDataStore()->getData(); $token->updateTokenProperties(); - $token->save(); + $this->saveToken($token, 'closed'); $token->setId($token->getKey()); } @@ -711,4 +711,37 @@ private function updateCaseStartedTask(TokenInterface $token): void $caseTaskRepo->updateCaseStartedTaskStatus(); $caseTaskRepo->updateCaseParticipatedTaskStatus(); } + + private function tokenPersistenceUsesRawSql(): bool + { + return (bool) config('app.token_persistence_raw_enabled', false); + } + + private function resolveNextUser(ActivityInterface $activity, TokenInterface $token): ?User + { + $processModel = $token->getInstance()->getProcess()->getOwnerDocument()->getModel(); + + if ($this->tokenPersistenceUsesRawSql()) { + return app(ProcessExecutionRawRepository::class)->getNextUserRaw($processModel, $activity, $token); + } + + return $processModel->getNextUser($activity, $token); + } + + private function saveToken(TokenInterface $token, string $context = 'activated'): void + { + if (!$this->tokenPersistenceUsesRawSql() || !$token instanceof ProcessRequestToken) { + $context === 'activated' ? $token->saveOrFail() : $token->save(); + + return; + } + + $repository = app(TokenPersistenceRawRepository::class); + + match ($context) { + 'completed' => $repository->saveCompletedToken($token), + 'closed' => $repository->saveClosedToken($token), + default => $repository->saveActivatedToken($token), + }; + } } diff --git a/ProcessMaker/Services/TaskCompletionRawService.php b/ProcessMaker/Services/TaskCompletionRawService.php new file mode 100644 index 0000000000..ca56244a9c --- /dev/null +++ b/ProcessMaker/Services/TaskCompletionRawService.php @@ -0,0 +1,126 @@ + $payload + * @return array + */ + public function completeTask(int $taskId, array $payload, User $user): array + { + if (!$this->isEnabled()) { + throw new NotFoundHttpException( + __('Task update API v1.1 is disabled. Use PUT /api/1.0/tasks/{id} instead.') + ); + } + + $taskRow = $this->repository->findTaskForUpdate($taskId); + + if ($taskRow === null) { + throw new NotFoundHttpException(__('Task not found')); + } + + if ($taskRow->status === 'CLOSED') { + abort(422, __('Task already closed')); + } + + $processRow = $this->repository->findProcessForComplete((int) $taskRow->process_id); + + if ($processRow === null) { + throw new NotFoundHttpException(__('Process not found')); + } + + Gate::forUser($user)->authorize( + 'update', + $this->engineBridge->hydrateTokenForPolicy($taskRow, $processRow) + ); + + $requestRow = $this->repository->findProcessRequestForComplete((int) $taskRow->process_request_id); + + if ($requestRow === null) { + throw new NotFoundHttpException(__('Process request not found')); + } + + $versionRow = $this->repository->findProcessVersionForComplete( + $requestRow->process_version_id ? (int) $requestRow->process_version_id : null + ); + + $data = SanitizeHelper::sanitizeData( + $payload['data'] ?? [], + null, + $requestRow->do_not_sanitize ?? [] + ); + + $this->engineBridge->complete( + $taskRow, + $processRow, + $requestRow, + $versionRow, + $data, + $this->repository->taskHasDraft($taskId), + ); + + $responseRow = $this->repository->findTaskForResponse($taskId); + + if ($responseRow === null) { + throw new NotFoundHttpException(__('Task not found')); + } + + return $this->formatTaskResponse($responseRow); + } + + /** + * @return array + */ + private function formatTaskResponse(object $row): array + { + return [ + 'id' => (int) $row->id, + 'element_name' => $row->element_name, + 'element_id' => $row->element_id, + 'element_type' => $row->element_type, + 'status' => $row->status, + 'due_at' => $row->due_at, + 'process_request_id' => (int) $row->process_request_id, + 'is_self_service' => (bool) $row->is_self_service, + 'token_properties' => $this->decodeJson($row->token_properties ?? null), + ]; + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode((string) $value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } +} diff --git a/ProcessMaker/Support/TaskCompletionEngineBridge.php b/ProcessMaker/Support/TaskCompletionEngineBridge.php new file mode 100644 index 0000000000..b3f697ceb1 --- /dev/null +++ b/ProcessMaker/Support/TaskCompletionEngineBridge.php @@ -0,0 +1,150 @@ +hydrateProcess($processRow); + $task = $this->hydrateModel( + ProcessRequestToken::class, + $this->encodeArrayCasts((array) $taskRow, ['self_service_groups']) + ); + $task->setRelation('process', $process); + + return $task; + } + + public function complete( + stdClass $taskRow, + stdClass $processRow, + stdClass $requestRow, + ?stdClass $versionRow, + array $data, + bool $hasDraft, + ): void { + if ($hasDraft && TaskDraft::draftsEnabled()) { + $task = $this->hydrateToken($taskRow, $requestRow, $processRow); + TaskDraft::moveDraftFiles($task); + } + + $process = $this->hydrateProcess($processRow); + $processVersion = $versionRow ? $this->hydrateProcessVersion($versionRow, $processRow) : null; + $instance = $this->hydrateProcessRequest($requestRow, $process, $processVersion); + $task = $this->hydrateToken($taskRow, $requestRow, $processRow, $instance, $process); + + WorkflowManager::completeTask($process, $instance, $task, $data); + } + + private function hydrateProcess(stdClass $row): Process + { + $attributes = (array) $row; + $properties = is_array($attributes['properties'] ?? null) + ? $attributes['properties'] + : []; + + if (!empty($row->manager_id)) { + $properties['manager_id'] = $row->manager_id; + } + + $attributes['properties'] = $properties; + unset($attributes['manager_id']); + + return $this->hydrateModel(Process::class, $this->encodeArrayCasts($attributes, ['properties', 'start_events'])); + } + + private function hydrateProcessVersion(stdClass $row, stdClass $processRow): ProcessVersion + { + $process = $this->hydrateProcess($processRow); + $version = $this->hydrateModel( + ProcessVersion::class, + $this->encodeArrayCasts((array) $row, ['start_events']) + ); + $version->setRelation('process', $process); + + return $version; + } + + private function hydrateProcessRequest( + stdClass $row, + Process $process, + ?ProcessVersion $processVersion, + ): ProcessRequest { + $instance = $this->hydrateModel( + ProcessRequest::class, + $this->encodeArrayCasts((array) $row, ['do_not_sanitize']) + ); + $instance->setRelation('process', $process); + + if ($processVersion !== null) { + $instance->setRelation('processVersion', $processVersion); + } + + return $instance; + } + + private function hydrateToken( + stdClass $taskRow, + stdClass $requestRow, + stdClass $processRow, + ?ProcessRequest $instance = null, + ?Process $process = null, + ): ProcessRequestToken { + $task = $this->hydrateModel( + ProcessRequestToken::class, + $this->encodeArrayCasts((array) $taskRow, ['self_service_groups']) + ); + + if ($instance === null || $process === null) { + $process ??= $this->hydrateProcess($processRow); + $instance ??= $this->hydrateProcessRequest($requestRow, $process, null); + } + + $task->setRelation('processRequest', $instance); + $task->setRelation('process', $process); + + return $task; + } + + private function hydrateModel(string $class, array $attributes): mixed + { + return $this->executionRawRepository->hydrateModelFromRowRaw($class, (object) $attributes); + } + + /** + * Eloquent array casts expect JSON strings in raw attributes. + * + * @param list $fields + */ + private function encodeArrayCasts(array $attributes, array $fields): array + { + foreach ($fields as $field) { + if (isset($attributes[$field]) && is_array($attributes[$field])) { + $attributes[$field] = json_encode($attributes[$field]); + } + } + + return $attributes; + } +} diff --git a/config/app.php b/config/app.php index b3c5891f98..a5477a2c6a 100644 --- a/config/app.php +++ b/config/app.php @@ -264,6 +264,12 @@ 'task_drafts_enabled' => env('TASK_DRAFTS_ENABLED', true), + // Raw-query PUT /api/1.1/tasks/{id} for optimized task completion (FOUR-32800). + 'task_update_v1_1_enabled' => env('TASK_UPDATE_V1_1_ENABLED', false), + + // Raw SQL for TokenRepository persistActivity* and getNextUser (FOUR-32800 option 3). + 'token_persistence_raw_enabled' => env('TOKEN_PERSISTENCE_RAW_ENABLED', false), + 'force_https' => env('FORCE_HTTPS', true), 'nayra_docker_network' => env('NAYRA_DOCKER_NETWORK', 'host'), diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index 10547dc52f..6da1cd0c6d 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -29,6 +29,10 @@ // Route to show the interstitial screen of a task Route::get('/{taskId}/interstitial', [TaskController::class, 'showInterstitial']) ->name('show.interstitial'); + + // Optimized task completion using raw queries (FOUR-32800). + Route::put('/{taskId}', [TaskController::class, 'update']) + ->name('update'); }); // Cases Endpoints diff --git a/tests/Feature/Api/V1_1/TaskControllerUpdateTest.php b/tests/Feature/Api/V1_1/TaskControllerUpdateTest.php new file mode 100644 index 0000000000..31ed8fcec7 --- /dev/null +++ b/tests/Feature/Api/V1_1/TaskControllerUpdateTest.php @@ -0,0 +1,115 @@ +create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + $response = $this->apiCall('PUT', route('api.1.1.tasks.update', $task->id), [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ]); + + $response->assertStatus(404); + $response->assertJsonFragment([ + 'message' => 'Task update API v1.1 is disabled. Use PUT /api/1.0/tasks/{id} instead.', + ]); + } + + public function testUpdateRejectsNonCompletionStatus(): void + { + Config::set('app.task_update_v1_1_enabled', true); + + $task = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + $response = $this->apiCall('PUT', route('api.1.1.tasks.update', $task->id), [ + 'user_id' => User::factory()->create()->id, + ]); + + $response->assertStatus(422); + } + + public function testUpdateCompletesTaskWhenEnabled(): void + { + Config::set('app.task_update_v1_1_enabled', true); + Config::set('app.token_persistence_raw_enabled', true); + + $request = ProcessRequest::factory()->create(); + $task = ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + WorkflowManager::shouldReceive('completeTask') + ->once() + ->with(Mockery::any(), Mockery::any(), Mockery::any(), ['foo' => 'bar']); + + $response = $this->apiCall('PUT', route('api.1.1.tasks.update', $task->id), [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ]); + + $response->assertStatus(200); + $response->assertJsonFragment([ + 'id' => $task->id, + 'status' => $task->status, + ]); + } + + public function testUpdateDeniesUnauthorizedUser(): void + { + Config::set('app.task_update_v1_1_enabled', true); + + $caller = User::factory()->create(['is_administrator' => false]); + $assignee = User::factory()->create(['is_administrator' => false]); + $task = ProcessRequestToken::factory()->create([ + 'user_id' => $assignee->id, + 'status' => 'ACTIVE', + ]); + + $response = $this->actingAs($caller, 'api')->json( + 'PUT', + '/api/' . preg_replace('/^.*\/api\//i', '', route('api.1.1.tasks.update', $task->id)), + [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ] + ); + + $response->assertStatus(403); + } +} diff --git a/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php new file mode 100644 index 0000000000..748a2022b1 --- /dev/null +++ b/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php @@ -0,0 +1,31 @@ +create(); + + $repository = new ProcessExecutionRawRepository(); + + $this->assertFalse($repository->taskHasDraftRaw($task->id)); + } + + public function testHydrateModelFromRowRawPreservesAttributes(): void + { + $user = User::factory()->create(); + + $repository = new ProcessExecutionRawRepository(); + $hydrated = $repository->hydrateModelFromRowRaw(User::class, (object) $user->getAttributes()); + + $this->assertSame($user->id, $hydrated->id); + $this->assertTrue($hydrated->exists); + } +} diff --git a/tests/unit/ProcessMaker/Repositories/TokenPersistenceRawRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/TokenPersistenceRawRepositoryTest.php new file mode 100644 index 0000000000..4d6cf01911 --- /dev/null +++ b/tests/unit/ProcessMaker/Repositories/TokenPersistenceRawRepositoryTest.php @@ -0,0 +1,64 @@ +create(); + $token = ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'status' => 'CLOSED', + 'element_name' => 'Old', + ]); + + $token->status = 'ACTIVE'; + $token->element_name = 'Updated Task'; + $token->user_id = $request->user_id; + + app(TokenPersistenceRawRepository::class)->saveActivatedToken($token); + + $this->assertDatabaseHas('process_request_tokens', [ + 'id' => $token->id, + 'status' => 'ACTIVE', + 'element_name' => 'Updated Task', + ]); + } + + public function testPersistInstanceUpdatedMergesDataWithoutEloquentSave(): void + { + $request = ProcessRequest::factory()->create([ + 'data' => ['existing' => 'value'], + 'execution_revision' => 2, + ]); + $request->loadProcessRequestInstance(); + + $request->getDataStore()->putData('new_key', 'new_value'); + $request->last_stage_name = 'Review'; + + app(TokenPersistenceRawRepository::class)->persistInstanceUpdated($request); + + $request->refresh(); + + $this->assertSame('value', $request->data['existing']); + $this->assertSame('new_value', $request->data['new_key']); + $this->assertSame(3, (int) $request->execution_revision); + $this->assertSame('Review', $request->last_stage_name); + } +}