diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 0679b42853..18eb82bec4 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -34,6 +34,7 @@ use ProcessMaker\Models\UserResourceView; use ProcessMaker\Notifications\TaskReassignmentNotification; use ProcessMaker\Query\SyntaxError; +use ProcessMaker\Repositories\ProcessExecutionRawRepository; use ProcessMaker\SanitizeHelper; use ProcessMaker\Traits\TaskControllerIndexMethods; @@ -41,6 +42,13 @@ class TaskController extends Controller { use TaskControllerIndexMethods; + private ?ProcessExecutionRawRepository $processExecutionRaw = null; + + private function processExecutionRaw(): ProcessExecutionRawRepository + { + return $this->processExecutionRaw ??= app(ProcessExecutionRawRepository::class); + } + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -336,6 +344,9 @@ public function show(ProcessRequestToken $task) */ public function update(Request $request, ProcessRequestToken $task) { + if (!$task->relationLoaded('process')) { + $task->setRelation('process', $this->processExecutionRaw()->getProcessForAuthorizeRaw($task->process_id)); + } $this->authorize('update', $task); if ($request->input('status') === 'COMPLETED') { if ($task->status === 'CLOSED') { @@ -343,21 +354,38 @@ public function update(Request $request, ProcessRequestToken $task) } // Skip ConvertEmptyStringsToNull and TrimStrings middlewares $data = json_optimize_decode($request->getContent(), true); - $data = SanitizeHelper::sanitizeData($data['data'], null, $task->processRequest->do_not_sanitize ?? []); + $instance = $this->processExecutionRaw()->getProcessRequestForCompleteRaw($task->process_request_id); + $data = SanitizeHelper::sanitizeData($data['data'], null, $instance->do_not_sanitize ?? []); + //Call the manager to trigger the start event - $process = $task->process; - $instance = $task->processRequest; - TaskDraft::moveDraftFiles($task); + $process = $this->processExecutionRaw()->getProcessForCompleteRaw($task->process_id); + $instance->setRelation('process', $process); + if ($processVersion = $this->processExecutionRaw()->getProcessVersionForCompleteRaw($instance->process_version_id)) { + $instance->setRelation('processVersion', $processVersion); + } + $task->setRelation('processRequest', $instance); + $task->setRelation('process', $process); + + if ($this->processExecutionRaw()->taskHasDraftRaw($task->id)) { + TaskDraft::moveDraftFiles($task); + } + WorkflowManager::completeTask($process, $instance, $task, $data); - return new Resource($task->refresh()); + $responseInstance = $this->processExecutionRaw()->getProcessRequestForResponseRaw($task->process_request_id); + $responseInstance->setRelation('process', $process); + $taskRefreshed = $this->processExecutionRaw()->refreshTaskRaw($task, $process, $responseInstance); + + return new Resource($taskRefreshed); } elseif (!empty($request->input('user_id'))) { + $process = $this->processExecutionRaw()->getProcessForReassignRaw($task->process_id); + $task->setRelation('process', $process); + $userToAssign = $request->input('user_id'); $comments = $request->input('comments'); $task->reassign($userToAssign, $request->user(), $comments); - $taskRefreshed = $task->refresh(); - + $taskRefreshed = $this->processExecutionRaw()->refreshTaskRaw($task, $process, $task->processRequest); CaseUpdate::dispatchSync($task->processRequest, $taskRefreshed); return new Resource($taskRefreshed); diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f78ddaf647..d962355794 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -194,13 +194,13 @@ private function lockInstance($instanceId) for ($tries = 0; $tries < $maxRetries; $tries++) { $currentLock = $this->currentLock($ids); if (!$currentLock) { - if (ProcessRequest::find($instanceId)) { + if (ProcessRequest::query()->whereKey($instanceId)->exists()) { $lock = $this->requestLock($ids); } else { throw new Exception('Unable to lock instance #' . $this->instanceId . ': Request does not exists'); } } elseif ($lock->id == $currentLock->id) { - $instance = ProcessRequest::findOrFail($instanceId); + $instance = $this->findProcessRequestForBpmnAction($instanceId); $this->activateLock($lock); return $instance; @@ -231,7 +231,7 @@ private function findInstanceWithRetry($instanceId) for ($attempt = 0; $attempt < $totalAttempts; $attempt++) { try { - $instance = ProcessRequest::findOrFail($instanceId); + $instance = $this->findProcessRequestForBpmnAction($instanceId); return $instance; } catch (ModelNotFoundException $e) { @@ -344,6 +344,23 @@ private function mSleep($milliseconds) usleep($microseconds); } + /** + * Load ProcessRequest with relations used when wiring the BPMN engine (reduces N+1 during completeTask / other BPMN jobs). + * + * @param int|string $instanceId + */ + private function findProcessRequestForBpmnAction($instanceId): ProcessRequest + { + return ProcessRequest::query() + ->with([ + 'process', + 'processVersion', + 'collaboration', + ]) + ->whereKey($instanceId) + ->firstOrFail(); + } + public function __destruct() { $this->instance = null; diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index fd27d18af9..f7390f1aaa 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -32,6 +32,7 @@ use ProcessMaker\Nayra\Managers\WorkflowManagerDefault; use ProcessMaker\Nayra\Storage\BpmnDocument; use ProcessMaker\Package\WebEntry\Models\WebentryRoute; +use ProcessMaker\Repositories\ProcessExecutionRawRepository; use ProcessMaker\Rules\BPMNValidation; use ProcessMaker\Traits\Exportable; use ProcessMaker\Traits\ExtendedPMQL; @@ -668,6 +669,14 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to return $this->checkAssignment($token->getInstance(), $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null, $token); } + /** + * @deprecated Use ProcessExecutionRawRepository::getNextUserRaw() + */ + public function getNextUserRaw(ActivityInterface $activity, ProcessRequestToken $token) + { + return app(ProcessExecutionRawRepository::class)->getNextUserRaw($this, $activity, $token); + } + /** * If user assignment is not valid reassign to Process Manager * @@ -708,7 +717,7 @@ private function checkAssignment(ProcessRequest $request, ActivityInterface $act return $user; } - private function scalateToManagerIfEnabled($user, $activity, $token, $assignmentType) + public function scalateToManagerIfEnabled($user, $activity, $token, $assignmentType) { if ($user) { $assignmentProcess = self::where('name', self::ASSIGNMENT_PROCESS)->first(); diff --git a/ProcessMaker/Models/ProcessRequest.php b/ProcessMaker/Models/ProcessRequest.php index 57509fe823..d243bac113 100644 --- a/ProcessMaker/Models/ProcessRequest.php +++ b/ProcessMaker/Models/ProcessRequest.php @@ -848,8 +848,13 @@ public function updateCatchEvents() public function mergeLatestStoredData() { $store = $this->getDataStore(); - $latest = self::select('data')->find($this->getId()); - $this->data = $store->updateArray($latest->data); + // Load only the data column (Eloquent cast applies) without hydrating the full row. + $latest = static::query()->whereKey($this->getKey())->first(['data']); + $latestData = $latest?->data ?? []; + if (!is_array($latestData)) { + $latestData = []; + } + $this->data = $store->updateArray($latestData); return $this->data; } diff --git a/ProcessMaker/Repositories/ProcessExecutionRawRepository.php b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php new file mode 100644 index 0000000000..eae200cf6e --- /dev/null +++ b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php @@ -0,0 +1,652 @@ +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 + ); + } + + /** + * Analog to $task->processRequest for completeTask (without loading data JSON). + */ + public function getProcessRequestForCompleteRaw(int $processRequestId): ProcessRequest + { + return ProcessRequest::query() + ->select(['do_not_sanitize', 'id', 'process_id', 'process_version_id', 'collaboration_uuid']) + ->findOrFail($processRequestId); + } + + /** + * Analog to $task->process for authorize('update') (properties only; manager_id is inside JSON). + */ + public function getProcessForAuthorizeRaw(int $processId): Process + { + return Process::query() + ->select(['id', 'properties']) + ->findOrFail($processId); + } + + /** + * Analog to $task->process for completeTask (BPMN + name; required by getDefinition/validateData). + */ + public function getProcessForCompleteRaw(int $processId): Process + { + return Process::query() + ->select(['id', 'name', 'bpmn']) + ->findOrFail($processId); + } + + /** + * Analog to $task->processRequest->processVersion when a version is pinned on the request. + */ + public function getProcessVersionForCompleteRaw(?int $processVersionId): ?ProcessVersion + { + if (!$processVersionId) { + return null; + } + + return ProcessVersion::query() + ->select(['id', 'process_id', 'bpmn']) + ->findOrFail($processVersionId); + } + + /** + * Analog to ProcessRequest::find columns needed for the task update response. + */ + public function getProcessRequestForResponseRaw(int $processRequestId): ProcessRequest + { + return ProcessRequest::query() + ->select([ + 'id', + 'process_id', + 'process_version_id', + 'collaboration_uuid', + 'do_not_sanitize', + 'data', + 'name', + 'status', + 'case_number', + 'case_title', + 'parent_request_id', + 'user_id', + 'uuid', + 'process_collaboration_id', + 'callable_id', + 'initiated_at', + 'completed_at', + 'created_at', + 'updated_at', + ]) + ->findOrFail($processRequestId); + } + + /** + * Analog to $task->process / Process::find for reassign (without loading bpmn). + */ + public function getProcessForReassignRaw(int $processId): Process + { + return Process::query() + ->select(['id', 'properties', 'stages', 'case_title']) + ->findOrFail($processId); + } + + /** + * Analog to $task->draft()->exists() (Eloquent). + */ + public function taskHasDraftRaw(int $taskId): bool + { + return (bool) DB::selectOne( + 'SELECT 1 AS found FROM task_drafts WHERE task_id = ? LIMIT 1', + [$taskId] + ); + } + + /** + * Analog to $task->refresh() (Eloquent), keeping preloaded relations for the response. + */ + public function refreshTaskRaw( + ProcessRequestToken $task, + Process $process, + ProcessRequest $instance + ): ProcessRequestToken { + $row = DB::selectOne( + 'SELECT * FROM process_request_tokens WHERE id = ? LIMIT 1', + [$task->id] + ); + if ($row) { + $task->setRawAttributes((array) $row, true); + $task->syncOriginal(); + } + $task->setRelation('process', $process); + $task->setRelation('processRequest', $instance); + + return $task; + } + + /** + * 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. + */ + public 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/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 77ad20fba9..cafba1a1ee 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -44,6 +44,8 @@ class TokenRepository implements TokenRepositoryInterface */ private $instanceRepository; + private ?ProcessExecutionRawRepository $processExecutionRaw = null; + /** * Initialize the Token Repository. * @@ -54,6 +56,11 @@ public function __construct(ExecutionInstanceRepository $instanceRepository) $this->instanceRepository = $instanceRepository; } + private function processExecutionRaw(): ProcessExecutionRawRepository + { + return $this->processExecutionRaw ??= app(ProcessExecutionRawRepository::class); + } + /** * Creates an instance of Token. * @@ -100,7 +107,8 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter if ($isScriptOrServiceTask) { $user = null; } else { - $user = $token->getInstance()->getProcess()->getOwnerDocument()->getModel()->getNextUser($activity, $token); + $processModel = $token->getInstance()->getProcess()->getOwnerDocument()->getModel(); + $user = $this->processExecutionRaw()->getNextUserRaw($processModel, $activity, $token); } $this->addUserToData($token->getInstance(), $user); $this->addRequestToData($token->getInstance()); diff --git a/tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php b/tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php new file mode 100644 index 0000000000..60d05ec96f --- /dev/null +++ b/tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php @@ -0,0 +1,117 @@ +assertLessThanOrEqual( + $maxMs, + $elapsedMs, + "{$label}: request took " . round($elapsedMs) . "ms, limit {$maxMs}ms (set TASK_UPDATE_MAX_RESPONSE_MS to adjust)" + ); + } + + public function testCompleteTaskUpdateResponseTime(): void + { + $maxMs = $this->maxResponseTimeMsForTaskUpdate(); + + $token = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + WorkflowManager::shouldReceive('completeTask') + ->once() + ->with(Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any()); + + $params = ['status' => 'COMPLETED', 'data' => ['foo' => 'bar']]; + + $started = microtime(true); + $response = $this->apiCall('PUT', '/tasks/' . $token->id, $params); + $elapsedMs = (microtime(true) - $started) * 1000; + + $response->assertStatus(200); + $this->assertResponseWithinMs($elapsedMs, $maxMs, 'PUT api/tasks/{id} (status=COMPLETED)'); + } + + public function testReassignTaskUpdateResponseTime(): void + { + $maxMs = $this->maxResponseTimeMsForTaskUpdate(); + + $assignee = User::factory()->create(); + $token = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + // Prevent notification errors by faking the notification system + // The factory generates random element_ids that don't exist in the BPMN document + Notification::fake(); + + $started = microtime(true); + $response = $this->apiCall('PUT', '/tasks/' . $token->id, [ + 'user_id' => $assignee->id, + 'comments' => 'response time test', + ]); + $elapsedMs = (microtime(true) - $started) * 1000; + + $response->assertStatus(200); + $this->assertResponseWithinMs($elapsedMs, $maxMs, 'PUT api/tasks/{id} (reassign)'); + } + + public function testCompleteTaskUpdateIncludesRequestData(): void + { + $token = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + $token->processRequest->update([ + 'data' => array_merge($token->processRequest->data ?? [], ['marker' => 'persisted']), + ]); + + WorkflowManager::shouldReceive('completeTask') + ->once() + ->with(Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any()); + + $response = $this->actingAs($this->user, 'api') + ->json('PUT', '/api/1.0/tasks/' . $token->id . '?include=requestData', [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ]); + + $response->assertStatus(200); + $response->assertJsonPath('request_data.marker', 'persisted'); + } +} diff --git a/tests/unit/ProcessMaker/Models/ProcessRequestTest.php b/tests/unit/ProcessMaker/Models/ProcessRequestTest.php index 30fc341da8..910b891519 100644 --- a/tests/unit/ProcessMaker/Models/ProcessRequestTest.php +++ b/tests/unit/ProcessMaker/Models/ProcessRequestTest.php @@ -85,4 +85,17 @@ public function testGetActiveTokens() $this->assertNotContains($completedToken->id, $collaborationActiveTokens); $this->assertNotContains($completedToken2->id, $collaborationActiveTokens); } + + public function testMergeLatestStoredDataLoadsDataColumnFromDatabase(): void + { + $request = ProcessRequest::factory()->create([ + 'data' => ['marker' => 'from_database'], + ]); + + $row = ProcessRequest::query()->whereKey($request->id)->first(['data']); + + $this->assertNotNull($row); + $this->assertIsArray($row->data); + $this->assertSame('from_database', $row->data['marker']); + } } diff --git a/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php new file mode 100644 index 0000000000..f7e17405ad --- /dev/null +++ b/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php @@ -0,0 +1,37 @@ +create([ + 'data' => ['marker' => 'should_not_load'], + ]); + + $repository = new ProcessExecutionRawRepository(); + $hydrated = $repository->getProcessRequestForCompleteRaw($request->id); + + $this->assertSame($request->id, $hydrated->id); + $this->assertArrayHasKey('do_not_sanitize', $hydrated->getAttributes()); + $this->assertArrayNotHasKey('data', $hydrated->getAttributes()); + } + + public function testGetProcessRequestForResponseRawIncludesDataColumn(): void + { + $request = ProcessRequest::factory()->create([ + 'data' => ['marker' => 'persisted'], + ]); + + $repository = new ProcessExecutionRawRepository(); + $hydrated = $repository->getProcessRequestForResponseRaw($request->id); + + $this->assertIsArray($hydrated->data); + $this->assertSame('persisted', $hydrated->data['marker']); + } +}