diff --git a/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php b/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php index c8a821fa14..fbc970c83f 100644 --- a/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php +++ b/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php @@ -6,6 +6,8 @@ use ProcessMaker\Exception\ThereIsNoProcessManagerAssignedException; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\User; use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; @@ -24,16 +26,62 @@ class ProcessManagerAssigned implements AssignmentRuleInterface * @param TokenInterface $token * @param Process $process * @param ProcessRequest $request - * @return int + * @return int|null * @throws ThereIsNoProcessManagerAssignedException */ public function getNextUser(ActivityInterface $task, TokenInterface $token, Process $process, ProcessRequest $request) { - $user_id = $request->processVersion->manager_id; + // review for multiple managers + $managers = $request->processVersion->manager_id; + $user_id = $this->getNextManagerAssigned($managers, $task, $request); if (!$user_id) { throw new ThereIsNoProcessManagerAssignedException($task); } return $user_id; } + + /** + * Get the round robin manager using a true round robin algorithm + * + * @param array $managers + * @param ActivityInterface $task + * @param ProcessRequest $request + * @return int|null + */ + private function getNextManagerAssigned($managers, $task, $request) + { + // Validate input + if (empty($managers) || !is_array($managers)) { + return null; + } + + // If only one manager, return it + if (count($managers) === 1) { + return $managers[0]; + } + + // get the last manager assigned to the task across all requests + $last = ProcessRequestToken::where('process_id', $request->process_id) + ->where('element_id', $task->getId()) + ->whereIn('user_id', $managers) + ->orderBy('created_at', 'desc') + ->first(); + + $user_id = $last ? $last->user_id : null; + + sort($managers); + + $key = array_search($user_id, $managers); + if ($key === false) { + // If no previous manager found, start with the first manager + $key = 0; + } else { + // Move to the next manager in the round-robin + $key = ($key + 1) % count($managers); + } + $user_id = $managers[$key]; + + return $user_id; + } } diff --git a/ProcessMaker/Console/Kernel.php b/ProcessMaker/Console/Kernel.php index d5f52d15e1..1d6ee38a81 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -88,6 +88,9 @@ protected function schedule(Schedule $schedule) $schedule->command('metrics:clear')->cron("*/{$clearInterval} * * * *"); break; } + + // 5 minutes is recommended in https://laravel.com/docs/12.x/horizon#metrics + $schedule->command('horizon:snapshot')->everyFiveMinutes(); } /** diff --git a/ProcessMaker/Exception/MultitenancyAccessedLandlord.php b/ProcessMaker/Exception/MultitenancyAccessedLandlord.php index d2bb0654ed..cfeb78fd3b 100644 --- a/ProcessMaker/Exception/MultitenancyAccessedLandlord.php +++ b/ProcessMaker/Exception/MultitenancyAccessedLandlord.php @@ -5,11 +5,21 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; +use ProcessMaker\Facades\Metrics; class MultitenancyAccessedLandlord extends Exception { public function render(Request $request): Response { + // If we're trying to access the /metrics route, collect landlord metrics and render them + if ($request->path() === 'metrics') { + Metrics::collectQueueMetrics(); + + return response(Metrics::renderMetrics(), 200, [ + 'Content-Type' => 'text/plain; version=0.0.4', + ]); + } + return response()->view('multitenancy.landlord-landing-page'); } diff --git a/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php b/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php index 008a3495fd..2d8198434c 100644 --- a/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php +++ b/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php @@ -14,36 +14,12 @@ class TenantQueueController extends Controller { - /** - * Constructor to check if tenant tracking is enabled. - */ - public function __construct() - { - // Check if tenant job tracking is enabled - $enabled = TenantQueueServiceProvider::enabled(); - - if (!$enabled) { - if (!app()->runningInConsole()) { - abort(404, 'Tenant queue tracking is disabled'); - } - } - - // If the route binding has a tenant id, check if the user is allowed to access the tenant queue - if ($id = (int) request()->route('tenantId')) { - if (!TenantQueueServiceProvider::allowAllTenats() && $id !== app('currentTenant')?->id) { - throw new AuthorizationException(); - } - } - } - /** * Show the tenant jobs dashboard. */ public function index() { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); return view('admin.tenant-queues.index'); } @@ -53,9 +29,7 @@ public function index() */ public function getTenants(): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantsWithJobs = TenantQueueServiceProvider::getTenantsWithJobs(); @@ -87,9 +61,7 @@ public function getTenants(): JsonResponse */ public function getTenantJobs(Request $request, string $tenantId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $status = $request->get('status'); $limit = min((int) $request->get('limit', 50), 100); // Max 100 jobs @@ -125,9 +97,7 @@ public function getTenantStats(string $tenantId): JsonResponse */ public function getOverallStats(): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantsWithJobs = TenantQueueServiceProvider::getTenantsWithJobs(); @@ -163,9 +133,7 @@ public function getOverallStats(): JsonResponse */ public function getJobDetails(string $tenantId, string $jobId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantKey = "tenant_jobs:{$tenantId}:{$jobId}"; $jobData = Redis::hgetall($tenantKey); @@ -199,9 +167,7 @@ public function getJobDetails(string $tenantId, string $jobId): JsonResponse */ public function clearTenantJobs(string $tenantId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); try { $pattern = "tenant_jobs:{$tenantId}:*"; @@ -228,4 +194,25 @@ public function clearTenantJobs(string $tenantId): JsonResponse return response()->json(['error' => 'Failed to clear tenant job data'], 500); } } + + private function checkPermissions(): void + { + // Check if tenant job tracking is enabled + $enabled = TenantQueueServiceProvider::enabled(); + + if (!$enabled) { + throw new AuthorizationException('Tenant queue tracking is disabled'); + } + + if (!Auth::user()->is_administrator) { + throw new AuthorizationException(); + } + + // If the route binding has a tenant id, check if the user is allowed to access the tenant queue + if ($id = (int) request()->route('tenantId')) { + if (!TenantQueueServiceProvider::allowAllTenats() && $id !== app('currentTenant')?->id) { + throw new AuthorizationException(); + } + } + } } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index b4fb419573..e8b29b9e25 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -429,7 +429,7 @@ public function store(Request $request) //set manager id if ($request->has('manager_id')) { - $process->manager_id = $request->input('manager_id', null); + $process->manager_id = $this->validateMaxManagers($request); } if (isset($data['bpmn'])) { @@ -542,7 +542,7 @@ public function update(Request $request, Process $process) $process->fill($request->except('notifications', 'task_notifications', 'notification_settings', 'cancel_request', 'cancel_request_id', 'start_request_id', 'edit_data', 'edit_data_id', 'projects')); if ($request->has('manager_id')) { - $process->manager_id = $request->input('manager_id', null); + $process->manager_id = $this->validateMaxManagers($request); } if ($request->has('user_id')) { @@ -621,6 +621,55 @@ public function update(Request $request, Process $process) return new Resource($process->refresh()); } + private function validateMaxManagers(Request $request) + { + $managerIds = $request->input('manager_id', []); + + // Handle different input types + if (is_string($managerIds)) { + // If it's a string, try to decode it as JSON + if (empty($managerIds)) { + $managerIds = []; + } else { + $decoded = json_decode($managerIds, true); + + // Handle JSON decode failure + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + ['manager_id' => [__('Invalid JSON format for manager_id')]] + ); + } + + $managerIds = $decoded; + } + } + + // Ensure we have an array + if (!is_array($managerIds)) { + // If it's a single value (not array), convert to array + $managerIds = [$managerIds]; + } + + // Filter out null, empty values and validate each manager ID + $managerIds = array_filter($managerIds, function ($id) { + return $id !== null && $id !== '' && is_numeric($id) && $id > 0; + }); + + // Re-index the array to remove gaps from filtered values + $managerIds = array_values($managerIds); + + // Validate maximum number of managers + if (count($managerIds) > 10) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + ['manager_id' => [__('Maximum number of managers is :max', ['max' => 10])]] + ); + } + + return $managerIds; + } + /** * Validate the structure of stages. * @@ -1714,7 +1763,7 @@ protected function checkUserCanStartProcess($event, $currentUser, $process, $req } break; case 'process_manager': - $response = $currentUser === $process->manager_id; + $response = in_array($currentUser, $process->manager_id ?? []); break; } } diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index ae85d35821..2564272691 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -59,7 +59,15 @@ public function index(Request $request) { $this->checkAuth($request); - return new ApiCollection(ScriptExecutor::nonSystem()->get()); + $query = ScriptExecutor::nonSystem(); + + if ($request->has('order_by')) { + $order_by = $request->input('order_by'); + $order_direction = $request->input('order_direction', 'ASC'); + $query->orderBy($order_by, $order_direction); + } + + return new ApiCollection($query->get()); } /** diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index cd4cb5fd1c..ee80c79975 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -146,15 +146,15 @@ public function index(Request $request, $getTotal = false, User $user = null) $this->applyAdvancedFilter($query, $request); - $this->applyForCurrentUser($query, $user); - - // Apply filter overdue - $query->overdue($request->input('overdue')); - if ($request->input('processesIManage') === 'true') { $this->applyProcessManager($query, $user); + } else { + $this->applyForCurrentUser($query, $user); } + // Apply filter overdue + $query->overdue($request->input('overdue')); + // If only the total is being requested (by a Saved Search), send it now if ($getTotal === true) { return $query->count(); @@ -168,6 +168,11 @@ public function index(Request $request, $getTotal = false, User $user = null) $response = $this->applyUserFilter($response, $request, $user); + if ($response->total() > 0 && $request->input('processesIManage') === 'true') { + // enable user manager in cache + $this->enableUserManager($user); + } + $inOverdueQuery = ProcessRequestToken::query() ->whereIn('id', $response->pluck('id')) ->where('due_at', '<', Carbon::now()); diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index 34c3d6c758..070483b348 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -209,10 +209,14 @@ public function getUsersTaskCount(Request $request) $include_ids = explode(',', $include_ids_string); } elseif ($request->has('assignable_for_task_id')) { $task = ProcessRequestToken::findOrFail($request->input('assignable_for_task_id')); - if ($task->getAssignmentRule() === 'user_group') { + $assignmentRule = $task->getAssignmentRule(); + if ($assignmentRule === 'user_group') { // Limit the list of users to those that can be assigned to the task $include_ids = $task->process->getAssignableUsers($task->element_id); } + if ($assignmentRule === 'rule_expression' && $request->has('form_data')) { + $include_ids = $task->getAssigneesFromExpression($request->input('form_data')); + } } if (!empty($include_ids)) { diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index 3649a001cf..c179bad86f 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -95,7 +95,7 @@ public function show($case_number) // The user can see the comments $canViewComments = (Auth::user()->hasPermissionsFor('comments')->count() > 0) || class_exists(PackageServiceProvider::class); // The user is Manager from the main request - $isProcessManager = $request->process?->manager_id === Auth::user()->id; + $isProcessManager = in_array(Auth::user()->id, $request->process?->manager_id ?? []); // Check if the user has permission print for request $canPrintScreens = $canOpenCase = $this->canUserCanOpenCase($allRequests); if (!$canOpenCase && !$isProcessManager) { diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index f8a5888430..e3f9e8a0f5 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -27,6 +27,7 @@ class Kernel extends HttpKernel ServerTimingMiddleware::class, Middleware\FileSizeCheck::class, Middleware\AddTenantHeaders::class, + Middleware\HideServerHeaders::class, ]; /** @@ -91,6 +92,7 @@ class Kernel extends HttpKernel 'session_kill' => Middleware\SessionControlKill::class, 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, + 'manager' => Middleware\IsManager::class, 'etag' => Middleware\Etag\HandleEtag::class, 'file_size_check' => Middleware\FileSizeCheck::class, ]; diff --git a/ProcessMaker/Http/Middleware/HideServerHeaders.php b/ProcessMaker/Http/Middleware/HideServerHeaders.php new file mode 100644 index 0000000000..31a9258648 --- /dev/null +++ b/ProcessMaker/Http/Middleware/HideServerHeaders.php @@ -0,0 +1,89 @@ +shouldHideHeaders()) { + // Remove all server-revealing headers + foreach ($this->headersToRemove as $header) { + $response->headers->remove($header); + } + + // Set a generic server header to avoid revealing the absence + $response->headers->set('Server', 'ProcessMaker Server'); + } + + return $response; + } + + /** + * Determine if headers should be hidden based on environment + * + * @return bool + */ + private function shouldHideHeaders(): bool + { + // Hide headers in production or when explicitly configured + return app()->environment('production') || + config('app.hide_server_headers', false); + } +} diff --git a/ProcessMaker/Http/Middleware/IsManager.php b/ProcessMaker/Http/Middleware/IsManager.php new file mode 100644 index 0000000000..638072f2be --- /dev/null +++ b/ProcessMaker/Http/Middleware/IsManager.php @@ -0,0 +1,195 @@ +user(); + + if (!$user) { + return abort(401, 'Unauthenticated'); + } + + // if user is administrator, allow access + if ($user->is_administrator) { + return $next($request); + } + + if (!Cache::get("user_{$user->id}_manager")) { + // if user is not manager, continue + return $next($request); + } + + // get the required permissions for this specific URL + $requiredPermissions = $this->getRequiredPermissionsForRequest($request); + + if (empty($requiredPermissions)) { + // if no required permissions, continue + return $next($request); + } + + // simulate that the user has all the necessary permissions for this request + $this->simulateRequiredPermissionsForRequest($user, $requiredPermissions); + + try { + // process the request - the internal endpoints will handle the permission validation + $response = $next($request); + + // clean up the simulated permissions after processing the request + $this->cleanupSimulatedPermission($user); + + return $response; + } catch (\Exception $e) { + // make sure to clean up the simulated permissions even if there is an exception + $this->cleanupSimulatedPermission($user); + throw $e; + } + } + + /** + * Simula que el usuario tiene los permisos requeridos solo para esta solicitud + */ + private function simulateRequiredPermissionsForRequest($user, array $requiredPermissions) + { + try { + // get the current permissions of the user + $currentPermissions = $user->loadPermissions(); + + // filter only the permissions that the user does not have + $permissionsToAdd = array_diff($requiredPermissions, $currentPermissions); + + if (empty($permissionsToAdd)) { + return; + } + + // simulate the permissions by adding them temporarily to the cache of permissions + $cacheKey = "user_{$user->id}_permissions"; + $simulatedPermissions = array_merge($currentPermissions, $permissionsToAdd); + + // save in cache temporarily (only for this request) + // use a very short time to expire quickly if not cleaned manually + Cache::put($cacheKey, $simulatedPermissions, 5); // 5 segundos como fallback + } catch (\Exception $e) { + Log::error('IsManager middleware - Error simulating permissions: ' . $e->getMessage()); + } + } + + /** + * clean up the simulated permissions from the cache after processing the request + */ + private function cleanupSimulatedPermission($user) + { + try { + $cacheKey = "user_{$user->id}_permissions"; + + // delete the cache to force the reload of real permissions + Cache::forget($cacheKey); + } catch (\Exception $e) { + Log::error('IsManager middleware - Error cleaning up simulated permissions: ' . $e->getMessage()); + } + } + + /** + * get the required permissions for the current URL + */ + private function getRequiredPermissionsForRequest(Request $request): array + { + $permissions = []; + + try { + $url = $request->fullUrl(); + $path = $request->path(); + $method = $request->method(); + + // first, get permissions from middlewares of the route + $middlewarePermissions = $this->getPermissionsFromMiddlewares($request); + $permissions = array_merge($permissions, $middlewarePermissions); + + // then, get permissions based on URL patterns + $urlPermissions = $this->getPermissionsFromUrlPatterns($url, $path, $method); + $permissions = array_merge($permissions, $urlPermissions); + } catch (\Exception $e) { + Log::error('IsManager middleware - Error getting required permissions: ' . $e->getMessage()); + } + + return array_unique($permissions); + } + + /** + * get permissions from the middlewares of the route + */ + private function getPermissionsFromMiddlewares(Request $request): array + { + $permissions = []; + + try { + // get all the middlewares of the route + $middlewares = $request->route()->middleware(); + + // filter only the middlewares that contain 'can:' + $permissionMiddlewares = array_filter($middlewares, function ($middleware) { + return str_contains($middleware, 'can:'); + }); + + // extract the permissions from each middleware + foreach ($permissionMiddlewares as $middleware) { + // format: "can:permission" or "can:permission,model" + if (preg_match('/can:([^,]+)/', $middleware, $matches)) { + $permissions[] = $matches[1]; + } + } + } catch (\Exception $e) { + Log::error('IsManager middleware - Error getting permissions from middlewares: ' . $e->getMessage()); + } + + return $permissions; + } + + /** + * get permissions based on URL patterns + */ + private function getPermissionsFromUrlPatterns(string $url, string $path, string $method): array + { + $permissions = []; + + // for now we only support GET methods + if ($method !== 'GET') { + return $permissions; + } + + try { + // URL patterns and their corresponding permissions + $urlPatterns = [ + // patterns for users + '/api\/.*\/users(\?.*)?$/' => 'view-users', + + // patterns for saved searches + '/api\/.*\/saved-searches\/columns(\?.*)?$/' => 'view-saved-searches-columns', + ]; + + // check each pattern + foreach ($urlPatterns as $pattern => $permission) { + if (preg_match($pattern, $url)) { + $permissions[] = $permission; + } + } + } catch (\Exception $e) { + Log::error('IsManager middleware - Error getting permissions from URL patterns: ' . $e->getMessage()); + } + + return $permissions; + } +} diff --git a/ProcessMaker/Http/Resources/Task.php b/ProcessMaker/Http/Resources/Task.php index cde9101604..fe19ce85b5 100644 --- a/ProcessMaker/Http/Resources/Task.php +++ b/ProcessMaker/Http/Resources/Task.php @@ -74,6 +74,8 @@ public function toArray($request) $this->addAssignableUsers($array, $include); + $this->mergeHitlCaseNumber($array); + return $array; } @@ -114,6 +116,28 @@ private function addAssignableUsers(&$array, $include) } } + private function mergeHitlCaseNumber(array &$array): void + { + if (!config('smart-extract.hitl_enabled')) { + return; + } + + if (!empty(data_get($array, 'process_request.case_number'))) { + return; + } + + $this->processRequest->loadMissing('parentRequest'); + $parentCaseNumber = $this->processRequest->parentRequest?->case_number; + if (!$parentCaseNumber) { + return; + } + + data_set($array, 'process_request.case_number', $parentCaseNumber); + if (empty($array['case_number'])) { + $array['case_number'] = $parentCaseNumber; + } + } + /** * Add the active users to the list of assigned users * @@ -131,7 +155,7 @@ private function addActiveAssignedUsers(array $users, array $assignedUsers) ->whereNotIn('status', Process::NOT_ASSIGNABLE_USER_STATUS) ->whereIn('id', $chunk) ->pluck('id')->toArray(); - $assignedUsers = array_merge($assignedUsers,$activeUsers); + $assignedUsers = array_merge($assignedUsers, $activeUsers); } return $assignedUsers; diff --git a/ProcessMaker/Http/Resources/V1_1/TaskResource.php b/ProcessMaker/Http/Resources/V1_1/TaskResource.php index cec32ced7e..26755cd5f5 100644 --- a/ProcessMaker/Http/Resources/V1_1/TaskResource.php +++ b/ProcessMaker/Http/Resources/V1_1/TaskResource.php @@ -107,6 +107,7 @@ class TaskResource extends ApiResource 'case_number', 'callable_id', 'process_version_id', + 'name', ], 'draft' => ['id', 'task_id', 'data'], 'screen' => ['id', 'config'], diff --git a/ProcessMaker/ImportExport/Exporters/ExporterBase.php b/ProcessMaker/ImportExport/Exporters/ExporterBase.php index 5e477b1928..f4ac9c4d90 100644 --- a/ProcessMaker/ImportExport/Exporters/ExporterBase.php +++ b/ProcessMaker/ImportExport/Exporters/ExporterBase.php @@ -308,8 +308,8 @@ public function toArray() 'dependents' => array_map(fn ($d) => $d->toArray(), $this->dependents), 'name' => $this->getName($this->model), 'description' => $this->getDescription(), - 'process_manager' => $this->getProcessManager()['managerName'], - 'process_manager_id' => $this->getProcessManager()['managerId'], + 'process_manager' => $this->getProcessManager(), + 'process_manager_id' => $this->getProcessManagerIds(), 'attributes' => $this->getExportAttributes(), 'extraAttributes' => $this->getExtraAttributes($this->model), 'references' => $this->references, @@ -383,10 +383,36 @@ public function getExtraAttributes($model): array public function getProcessManager(): array { - return [ - 'managerId' => $this->model->manager?->id ? $this->model->manager->id : null, - 'managerName' => $this->model->manager?->fullname ? $this->model->manager->fullname : '', - ]; + // Check if the model has the getManagers method + if (!method_exists($this->model, 'getManagers')) { + return []; + } + + $managers = $this->model->getManagers() ?? []; + + $managerNames = []; + foreach ($managers as $manager) { + $managerNames[] = $manager->fullname; + } + + return $managerNames; + } + + public function getProcessManagerIds(): array + { + // Check if the model has the getManagers method + if (!method_exists($this->model, 'getManagers')) { + return []; + } + + $managers = $this->model->getManagers() ?? []; + + $managerIds = []; + foreach ($managers as $manager) { + $managerIds[] = $manager->id; + } + + return $managerIds; } public function getLastModifiedBy() : array diff --git a/ProcessMaker/ImportExport/Exporters/ProcessExporter.php b/ProcessMaker/ImportExport/Exporters/ProcessExporter.php index d5a88927df..1fdd81fc6a 100644 --- a/ProcessMaker/ImportExport/Exporters/ProcessExporter.php +++ b/ProcessMaker/ImportExport/Exporters/ProcessExporter.php @@ -38,8 +38,12 @@ public function export() : void $this->addDependent('user', $process->user, UserExporter::class); } - if ($process->manager) { - $this->addDependent('manager', $process->manager, UserExporter::class, null, ['properties']); + $managers = $process->getManagers(); + + if ($managers) { + foreach ($managers as $manager) { + $this->addDependent('manager', $manager, UserExporter::class, null, ['properties']); + } } $this->exportScreens(); @@ -99,9 +103,11 @@ public function import($existingAssetInDatabase = null, $importingFromTemplate = $process->user_id = User::where('is_administrator', true)->firstOrFail()->id; } + $managers = []; foreach ($this->getDependents('manager') as $dependent) { - $process->manager_id = $dependent->model->id; + $managers[] = $dependent->model->id; } + $process->manager_id = $managers; // Avoid associating the category from the manifest with processes imported from templates. // Use the user-selected category instead. diff --git a/ProcessMaker/ImportExport/Exporters/ScreenExporter.php b/ProcessMaker/ImportExport/Exporters/ScreenExporter.php index 03370ed26d..119803feae 100644 --- a/ProcessMaker/ImportExport/Exporters/ScreenExporter.php +++ b/ProcessMaker/ImportExport/Exporters/ScreenExporter.php @@ -97,11 +97,12 @@ private function getNestedScreens() : array $screenFinder = new ScreensInScreen(); foreach ($screenFinder->referencesToExport($this->model, [], null, false) as $screen) { try { - $screen = Screen::find($screen[1]); + $screenId = $screen[1]; + $screen = Screen::find($screenId); if ($screen) { $screens[] = $screen; } else { - \Log::debug("NestedScreen screenId: $screen[1] not exists"); + \Log::debug("NestedScreen screenId: $screenId not exists"); } } catch (ModelNotFoundException $error) { \Log::error($error->getMessage()); diff --git a/ProcessMaker/Jobs/ErrorHandling.php b/ProcessMaker/Jobs/ErrorHandling.php index ea0049a742..e6ec5df652 100644 --- a/ProcessMaker/Jobs/ErrorHandling.php +++ b/ProcessMaker/Jobs/ErrorHandling.php @@ -93,10 +93,11 @@ private function requeue($job) public function sendExecutionErrorNotification(string $message) { if ($this->processRequestToken) { - $user = $this->processRequestToken->processRequest->processVersion->manager; - if ($user !== null) { - Log::info('Send Execution Error Notification: ' . $message); - Notification::send($user, new ErrorExecutionNotification($this->processRequestToken, $message, $this->bpmnErrorHandling)); + // review no multiple managers + $mangers = $this->processRequestToken->processRequest->processVersion->getManagers(); + foreach ($mangers as $manager) { + Log::info('Send Execution Error Notification: ' . $message . ' to manager: ' . $manager->username); + Notification::send($manager, new ErrorExecutionNotification($this->processRequestToken, $message, $this->bpmnErrorHandling)); } } } @@ -213,7 +214,7 @@ public static function convertResponseToException($result) if (str_starts_with($result['message'], 'Command exceeded timeout of')) { throw new ScriptTimeoutException($result['message']); } - throw new ScriptException($result['message']); + throw new ScriptException(json_encode($result, JSON_PRETTY_PRINT)); } } } diff --git a/ProcessMaker/Listeners/CommentsSubscriber.php b/ProcessMaker/Listeners/CommentsSubscriber.php index 7e56b72727..f8ab44b4cb 100644 --- a/ProcessMaker/Listeners/CommentsSubscriber.php +++ b/ProcessMaker/Listeners/CommentsSubscriber.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Listeners; +use Illuminate\Support\Facades\Auth; use ProcessMaker\Models\Comment; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; @@ -26,11 +27,27 @@ public function onActivityCompleted(ActivityCompletedEvent $event) $user_id = $token->user ? $token->user_id : null; $user_name = $token->user ? $token->user->fullname : __('The System'); + // Check the authenticated user + $executer_user_id = null; + $executer_user = __('The System'); + $user = Auth::user(); + if ($user) { + $executer_user_id = $user->id; + $executer_user = $user->fullname; + } + if (!is_int($token->process_request_id)) { return; } - $message = ':user has completed the task :task_name'; + if (!is_null($executer_user_id) && $user_id !== $executer_user_id) { + $subject = 'Task Completed by Different User'; + $message = ':executer_user has completed the task :task_name (assigned to: :user)'; + } else { + $subject = 'Task Complete'; + $message = ':user has completed the task :task_name'; + } + if ($token->is_actionbyemail) { $message = $message . ' via email'; } @@ -42,8 +59,8 @@ public function onActivityCompleted(ActivityCompletedEvent $event) 'user_id' => $user_id, 'commentable_type' => ProcessRequest::class, 'commentable_id' => $token->process_request_id, - 'subject' => 'Task Complete', - 'body' => __($message, ['user' => $user_name, 'task_name' => $token->element_name]), + 'subject' => $subject, + 'body' => __($message, ['user' => $user_name, 'task_name' => $token->element_name, 'executer_user' => $executer_user]), 'case_number' => $caseNumber, ]); } diff --git a/ProcessMaker/Mail/TaskActionByEmail.php b/ProcessMaker/Mail/TaskActionByEmail.php index 6f453a8d5b..1a21df63f4 100644 --- a/ProcessMaker/Mail/TaskActionByEmail.php +++ b/ProcessMaker/Mail/TaskActionByEmail.php @@ -51,8 +51,9 @@ public function sendAbeEmail($config, $to, $data) if (!empty($emailScreenRef)) { // Retrieve and render custom screen if specified $customScreen = Screen::findOrFail($emailScreenRef); - $infoRenderer = $this->emailProvider->screenRenderer($customScreen->config, $data); + $infoRenderer = $this->emailProvider->screenRenderer($customScreen->config, $data, $customScreen); $emailConfig['body'] = $infoRenderer['body'] ?? __('No body configured'); + $emailConfig['styles'] = $infoRenderer['styles'] ?? ''; } else { // Default message if no custom screen is configured $emailConfig['body'] = __('No screen configured'); diff --git a/ProcessMaker/Managers/ExportManager.php b/ProcessMaker/Managers/ExportManager.php index ffb84347d2..859afac8f0 100644 --- a/ProcessMaker/Managers/ExportManager.php +++ b/ProcessMaker/Managers/ExportManager.php @@ -83,13 +83,11 @@ private function reviewDependenciesOf(ProcessMakerModel $owner, array $reference $newReferences = $this->uniqueDiff($newReferences, $references); $references = array_merge($references, $newReferences); // Find recursively dependencies - if ($recursive) { - foreach ($newReferences as $ref) { - list($class, $id) = $ref; - $nextOwner = $class::find($id); - if ($nextOwner) { - $references = $this->reviewDependenciesOf($nextOwner, $references, $reviewed, $recursive); - } + foreach ($newReferences as $ref) { + list($class, $id) = $ref; + $nextOwner = $class::find($id); + if ($nextOwner) { + $references = $this->reviewDependenciesOf($nextOwner, $references, $reviewed, $recursive); } } diff --git a/ProcessMaker/Models/FormalExpression.php b/ProcessMaker/Models/FormalExpression.php index 0f42344016..838944d60e 100644 --- a/ProcessMaker/Models/FormalExpression.php +++ b/ProcessMaker/Models/FormalExpression.php @@ -239,8 +239,23 @@ function ($__data, $user_id, $assigned_groups) { // If no manager is found, then assign the task to the Process Manager. $request = ProcessRequest::find($__data['_request']['id']); $process = $request->processVersion; + $managers = $process->manager_id ?? []; - return $process->manager_id; + if (empty($managers)) { + return null; + } + + // Sort managers to ensure consistent round robin distribution + sort($managers); + + // Use a combination of process ID and request ID for better distribution + // This ensures different processes don't interfere with each other's round robin + $processId = $process->id ?? 0; + $requestId = $__data['_request']['id'] ?? 0; + $seed = $processId + $requestId; + $managerIndex = $seed % count($managers); + + return $managers[$managerIndex]; } return $user->manager_id; diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index 1a7b5da4b6..e7adbba6d8 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -86,7 +86,7 @@ * @OA\Property(property="self_service_tasks", type="object"), * @OA\Property(property="signal_events", type="array", @OA\Items(type="object")), * @OA\Property(property="category", type="object", @OA\Schema(ref="#/components/schemas/ProcessCategory")), - * @OA\Property(property="manager_id", type="integer", format="id"), + * @OA\Property(property="manager_id", type="array", @OA\Items(type="integer", format="id")), * ), * @OA\Schema( * schema="Process", @@ -589,7 +589,7 @@ public function collaborations() * Get the user to whom to assign a task. * * @param ActivityInterface $activity - * @param TokenInterface $token + * @param ProcessRequestToken $token * * @return User */ @@ -613,14 +613,14 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to if ($userByRule !== null) { $user = $this->scalateToManagerIfEnabled($userByRule->id, $activity, $token, $assignmentType); - return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null); + return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null, $token); } } if (filter_var($assignmentLock, FILTER_VALIDATE_BOOLEAN) === true) { $user = $this->getLastUserAssignedToTask($activity->getId(), $token->getInstance()->getId()); if ($user) { - return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, User::where('id', $user)->first()); + return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, User::where('id', $user)->first(), $token); } } @@ -665,7 +665,7 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to $user = $this->scalateToManagerIfEnabled($user, $activity, $token, $assignmentType); - return $this->checkAssignment($token->getInstance(), $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null); + return $this->checkAssignment($token->getInstance(), $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null, $token); } /** @@ -676,10 +676,11 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to * @param string $assignmentType * @param bool $escalateToManager * @param User|null $user + * @param ProcessRequestToken $token * * @return User|null */ - private function checkAssignment(ProcessRequest $request, ActivityInterface $activity, $assignmentType, $escalateToManager, User $user = null) + private function checkAssignment(ProcessRequest $request, ActivityInterface $activity, $assignmentType, $escalateToManager, User $user = null, ProcessRequestToken $token = null) { $config = $activity->getProperty('config') ? json_decode($activity->getProperty('config'), true) : []; $selfServiceToggle = array_key_exists('selfService', $config ?? []) ? $config['selfService'] : false; @@ -693,10 +694,15 @@ private function checkAssignment(ProcessRequest $request, ActivityInterface $act if ($isSelfService && !$escalateToManager) { return null; } - $user = $request->processVersion->manager; + $rule = new ProcessManagerAssigned(); + if ($token === null) { + throw new ThereIsNoProcessManagerAssignedException($activity); + } + $user = $rule->getNextUser($activity, $token, $this, $request); if (!$user) { throw new ThereIsNoProcessManagerAssignedException($activity); } + $user = User::find($user); } return $user; @@ -1147,7 +1153,7 @@ public function getStartEvents($filterWithPermissions = false, $filterWithoutAss } } } elseif (isset($startEvent['assignment']) && $startEvent['assignment'] === 'process_manager') { - $access = $this->manager && $this->manager->id && $this->manager->id === $user->id; + $access = in_array($user->id, $this->manager_id ?? []); } else { $access = false; } diff --git a/ProcessMaker/Models/ProcessRequest.php b/ProcessMaker/Models/ProcessRequest.php index a75db1a87f..57509fe823 100644 --- a/ProcessMaker/Models/ProcessRequest.php +++ b/ProcessMaker/Models/ProcessRequest.php @@ -280,7 +280,7 @@ public function getNotifiableUserIds($notifiableType) case 'participants': return $this->participants()->get()->pluck('id'); case 'manager': - return collect([$this->process()->first()->manager_id]); + return collect($this->process()->first()->manager_id ?? []); default: return collect([]); } diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 082a2fa3ed..6abf47e02d 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -30,6 +30,7 @@ use ProcessMaker\Traits\HasUuids; use ProcessMaker\Traits\HideSystemResources; use ProcessMaker\Traits\SerializeToIso8601; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Throwable; /** @@ -256,7 +257,7 @@ public function getNotifiableUserIds($notifiableType) case 'manager': $process = $this->process()->first(); - return collect([$process?->manager_id]); + return collect($process?->manager_id ?? []); break; default: return collect([]); @@ -968,6 +969,66 @@ public function getAssignmentRule() return $assignment; } + /** + * Get the assignees for the token. + * + * @param array $assignments + * @param array $variables + * @return array + */ + public function getAssignees(array $assignments, array $variables): array + { + $result = []; + $language = new ExpressionLanguage(); + + foreach ($assignments as $assignment) { + $isTrue = false; + + if (!empty($assignment['expression'])) { + try { + $isTrue = $language->evaluate($assignment['expression'], $variables); + } catch (Throwable $e) { + $isTrue = false; + } + } + + if ($isTrue) { + $result[] = $assignment['assignee']; + } + + if (isset($assignment['default']) && $assignment['default'] === true) { + $result[] = $assignment['assignee']; + } + } + + return $result; + } + + /** + * Get the assignees from the expression + * + * @param string $form_data + * @return array + */ + public function getAssigneesFromExpression(string $form_data): array + { + $formData = json_decode($form_data, true); + + $activity = $this->getBpmnDefinition()->getBpmnElementInstance(); + $assignmentRules = $activity->getProperty('assignmentRules', null); + $assignments = json_decode($assignmentRules, true); + + $include_ids = $this->getAssignees($assignments, $formData); + + // we add the manager to the list of assignees + $manager_id = $this->process->manager_id; + if ($manager_id) { + $include_ids[] = $manager_id; + } + + return $include_ids; + } + /** * Returns if the token has the self service option activated */ diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index 1902a0b8e5..eb5e319881 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -55,11 +55,6 @@ private function overrideConfigs(Application $app, IsTenant $tenant) 'app.instance' => config('app.instance') . '_' . $tenant->id, ]; - if (!isset($tenant->config['cache.stores.cache_settings.prefix'])) { - $newConfig['cache.stores.cache_settings.prefix'] = - 'tenant_id_' . $tenant->id . ':' . $tenant->getOriginalValue('CACHE_SETTING_PREFIX'); - } - if (!isset($tenant->config['script-runner-microservice.callback'])) { $newConfig['script-runner-microservice.callback'] = str_replace( $tenant->getOriginalValue('APP_URL'), diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php index 44897c4763..dc71c3f0d0 100644 --- a/ProcessMaker/Multitenancy/TenantBootstrapper.php +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Multitenancy; +use Dotenv\Dotenv; use Illuminate\Encryption\Encrypter; use Illuminate\Http\Request; use Illuminate\Support\Env; @@ -34,6 +35,7 @@ class TenantBootstrapper 'REDIS_PREFIX', 'CACHE_SETTING_PREFIX', 'SCRIPT_MICROSERVICE_CALLBACK', + 'CACHE_PREFIX', ]; public function bootstrap(Application $app) @@ -43,8 +45,6 @@ public function bootstrap(Application $app) } $this->app = $app; - self::saveLandlordValues($app); - $tenantData = null; // Try to find tenant by ID first if TENANT env var is set @@ -87,6 +87,10 @@ private function setTenantEnvironmentVariables($tenantData) $this->set('DB_DATABASE', $tenantData['database']); $this->set('DB_USERNAME', $tenantData['username'] ?? $this->getOriginalValue('DB_USERNAME')); + // Do not set REDIS_PREFIX because it is used by the queue (not tenant specific) + $this->set('CACHE_PREFIX', 'tenant_' . $tenantData['id'] . ':' . $this->getOriginalValue('CACHE_PREFIX')); + $this->set('CACHE_SETTING_PREFIX', 'tenant_' . $tenantData['id'] . ':' . $this->getOriginalValue('CACHE_SETTING_PREFIX')); + $encryptedPassword = $tenantData['password']; $password = null; if ($encryptedPassword) { @@ -99,21 +103,12 @@ private function setTenantEnvironmentVariables($tenantData) $this->set('LOG_PATH', $this->app->storagePath('logs/processmaker.log')); } - public static function saveLandlordValues($app) + private function getOriginalValue($key) { - if ($app->has('landlordValues')) { - self::$landlordValues = $app->make('landlordValues'); - - return; + if (self::$landlordValues === []) { + self::$landlordValues = Dotenv::parse(file_get_contents(base_path('.env'))); } - foreach (self::$landlordKeysToSave as $key) { - self::$landlordValues[$key] = $_SERVER[$key] ?? ''; - } - } - - private function getOriginalValue($key) - { if (!isset(self::$landlordValues[$key])) { return ''; } diff --git a/ProcessMaker/Policies/ProcessPolicy.php b/ProcessMaker/Policies/ProcessPolicy.php index efe717c0a1..192fa02a75 100644 --- a/ProcessMaker/Policies/ProcessPolicy.php +++ b/ProcessMaker/Policies/ProcessPolicy.php @@ -17,7 +17,7 @@ class ProcessPolicy * Run before all methods to determine if the * user is an admin and can do everything. * - * @param \ProcessMaker\Models\User $user + * @param User $user * @return mixed */ public function before(User $user) @@ -39,8 +39,8 @@ public function edit(User $user, Process $process) /** * Determine whether the user can start the process. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\Process $process + * @param User $user + * @param Process $process * @return mixed */ public function start(User $user, Process $process) @@ -60,7 +60,7 @@ public function start(User $user, Process $process) $userCanStartAsProcessManager = array_reduce($process->getStartEvents(), function ($carry, $item) use ($process, $user) { if (array_key_exists('assignment', $item)) { - $carry = $carry || ($item['assignment'] === 'process_manager' && $process->manager_id === $user->id); + $carry = $carry || ($item['assignment'] === 'process_manager' && in_array($user->id, $process->manager_id ?? [])); } return $carry; @@ -81,8 +81,8 @@ function ($carry, $item) use ($process, $user) { /** * Determine whether the user can cancel the process. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\Process $process + * @param User $user + * @param Process $process * * @return bool */ @@ -99,7 +99,7 @@ public function cancel(User $user, Process $process) } if ( - $process->manager_id === $user->id && + in_array($user->id, $process->manager_id ?? []) && $process->getProperty('manager_can_cancel_request') === true ) { return true; @@ -111,8 +111,8 @@ public function cancel(User $user, Process $process) /** * Determine whether the user can edit data. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\Process $process + * @param User $user + * @param Process $process * * @return bool */ diff --git a/ProcessMaker/Policies/ProcessRequestTokenPolicy.php b/ProcessMaker/Policies/ProcessRequestTokenPolicy.php index b6d47a3dea..ff51cf7556 100644 --- a/ProcessMaker/Policies/ProcessRequestTokenPolicy.php +++ b/ProcessMaker/Policies/ProcessRequestTokenPolicy.php @@ -38,7 +38,7 @@ public function before(User $user) public function view(User $user, ProcessRequestToken $processRequestToken) { if ($processRequestToken->user_id == $user->id || - $processRequestToken->process?->manager_id === $user->id + in_array($user->id, $processRequestToken->process?->manager_id ?? []) ) { return true; } @@ -59,7 +59,7 @@ public function update(User $user, ProcessRequestToken $processRequestToken) if ( $processRequestToken->user_id === $user->id || $processRequestToken->user_id === app(AnonymousUser::class)->id || - $processRequestToken->process?->manager_id === $user->id + in_array($user->id, $processRequestToken->process?->manager_id ?? []) ) { return true; } @@ -90,13 +90,13 @@ public function viewScreen(User $user, ProcessRequestToken $task): bool public function rollback(User $user, ProcessRequestToken $task) { // For now, only the process manager can rollback the request - return $user->id === $task->process->managerId; + return in_array($user->id, $task->process?->manager_id ?? []); } public function reassign(User $user, ProcessRequestToken $task) { // If user is process manager - if ($user->id === $task->process->managerId) { + if (in_array($user->id, $task->process?->manager_id ?? [])) { return true; } diff --git a/ProcessMaker/Policies/ProcessVersionPolicy.php b/ProcessMaker/Policies/ProcessVersionPolicy.php index d8a653df90..b7b93ba54a 100644 --- a/ProcessMaker/Policies/ProcessVersionPolicy.php +++ b/ProcessMaker/Policies/ProcessVersionPolicy.php @@ -14,7 +14,7 @@ class ProcessVersionPolicy * Run before all methods to determine if the * user is an admin and can do everything. * - * @param \ProcessMaker\Models\User $user + * @param User $user * @return mixed */ public function before(User $user) @@ -27,8 +27,8 @@ public function before(User $user) /** * Determine whether the user can cancel the process version. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\ProcessVersion $processVersion + * @param User $user + * @param ProcessVersion $processVersion * * @return bool */ @@ -45,7 +45,7 @@ public function cancel(User $user, ProcessVersion $processVersion) } if ( - $processVersion->manager_id === $user->id && + in_array($user->id, $processVersion->manager_id ?? []) && $processVersion->getProperty('manager_can_cancel_request') === true ) { return true; @@ -57,8 +57,8 @@ public function cancel(User $user, ProcessVersion $processVersion) /** * Determine whether the user can edit data * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\ProcessVersion $processVersion + * @param User $user + * @param ProcessVersion $processVersion * * @return bool */ diff --git a/ProcessMaker/Providers/TenantQueueServiceProvider.php b/ProcessMaker/Providers/TenantQueueServiceProvider.php index 544650b22f..b603718e2d 100644 --- a/ProcessMaker/Providers/TenantQueueServiceProvider.php +++ b/ProcessMaker/Providers/TenantQueueServiceProvider.php @@ -286,7 +286,7 @@ protected function updateTenantJobLists(string $tenantId, string $jobId, string } else { // If job is already in the list, move it to the front (most recent) Redis::pipeline(function ($pipe) use ($listKey, $jobId) { - $pipe->lrem($listKey, 0, $jobId); + $pipe->lrem($listKey, $jobId, 0); $pipe->lpush($listKey, $jobId); $pipe->expire($listKey, 86400); // Expire in 24 hours }); diff --git a/ProcessMaker/Services/MetricsService.php b/ProcessMaker/Services/MetricsService.php index 20ae9b8862..824f12b6cc 100644 --- a/ProcessMaker/Services/MetricsService.php +++ b/ProcessMaker/Services/MetricsService.php @@ -3,13 +3,18 @@ namespace ProcessMaker\Services; use Exception; +use Laravel\Horizon\Contracts\JobRepository; +use Laravel\Horizon\Contracts\MetricsRepository; +use Laravel\Horizon\Contracts\WorkloadRepository; use ProcessMaker\Facades\Metrics; +use ProcessMaker\Multitenancy\Tenant; use Prometheus\CollectorRegistry; use Prometheus\Counter; use Prometheus\Gauge; use Prometheus\Histogram; use Prometheus\RenderTextFormat; -use Prometheus\Storage\Redis; +use Prometheus\Storage\Redis as PrometheusRedis; +use Redis; use RuntimeException; class MetricsService @@ -39,7 +44,12 @@ public function __construct(private $adapter = null) try { // Set up Redis as the adapter if none is provided if ($adapter === null) { - $adapter = Redis::fromExistingConnection(app('redis')->client()); + $redis = app('redis')->client(); + $adapter = PrometheusRedis::fromExistingConnection($redis); + if (app()->has(Tenant::BOOTSTRAPPED_TENANT)) { + $tenantInfo = app(Tenant::BOOTSTRAPPED_TENANT); + $adapter->setPrefix('tenant_' . $tenantInfo['id'] . ':PROMETHEUS_'); + } } $this->collectionRegistry = new CollectorRegistry($adapter); } catch (Exception $e) { @@ -65,7 +75,7 @@ public function getCollectionRegistry(): CollectorRegistry * @param array $labels The labels of the counter. * @return Counter The registered or retrieved counter. */ - public function counter(string $name, string $help = null, array $labels = []): Counter + public function counter(string $name, string|null $help = null, array $labels = []): Counter { $help = $help ?? $name; @@ -85,7 +95,7 @@ public function counter(string $name, string $help = null, array $labels = []): * @param array $labels The labels of the gauge. * @return Gauge The registered or retrieved gauge. */ - public function gauge(string $name, string $help = null, array $labels = []): Gauge + public function gauge(string $name, string|null $help = null, array $labels = []): Gauge { $help = $help ?? $name; @@ -201,7 +211,9 @@ public function addSystemLabels(array $labels) // Add system labels $labels['app_version'] = $this->getApplicationVersion(); $labels['app_name'] = config('app.name'); - $labels['app_custom_label'] = config('app.prometheus_custom_label'); + if (config('app.prometheus_custom_label')) { + $labels['app_custom_label'] = config('app.prometheus_custom_label'); + } return $labels; } @@ -223,4 +235,29 @@ private function getApplicationVersion() return $composer_json_path->version ?? '4.0.0'; } + + /** + * These are collected every time the /metrics route is accessed. + * + * @return void + */ + public function collectQueueMetrics(): void + { + $metricsRepository = app(MetricsRepository::class); + $jobsRepository = app(JobRepository::class); + $workloadRepository = app(WorkloadRepository::class); + + $this->gauge('horizon_jobs_per_minute', 'Jobs processed per minute')->set($metricsRepository->jobsProcessedPerMinute()); + $this->gauge('horizon_failed_jobs_per_hour', 'Failed jobs per hour')->set($jobsRepository->countRecentlyFailed()); + + foreach ($workloadRepository->get() as $workload) { + $name = $workload['name']; + foreach (['length', 'wait', 'processes'] as $type) { + $this->gauge( + 'horizon_workload_' . $name . '_' . $type, + 'Workload ' . $name . ' ' . $type + )->set($workload[$type]); + } + } + } } diff --git a/ProcessMaker/Traits/ProcessTrait.php b/ProcessMaker/Traits/ProcessTrait.php index 0bd2f4b7f4..739cba645e 100644 --- a/ProcessMaker/Traits/ProcessTrait.php +++ b/ProcessMaker/Traits/ProcessTrait.php @@ -4,7 +4,6 @@ use ProcessMaker\Models\Group; use ProcessMaker\Models\Process; -use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessVersion; use ProcessMaker\Models\User; use ProcessMaker\Nayra\Contracts\Storage\BpmnDocumentInterface; @@ -85,7 +84,7 @@ public function getProperty($name) /** * Set the manager id * - * @param int $value + * @param int|array $value * @return void */ public function setManagerIdAttribute($value) @@ -96,23 +95,85 @@ public function setManagerIdAttribute($value) /** * Get the the manager id * - * @return int|null + * @return array|null */ public function getManagerIdAttribute() { $property = $this->getProperty('manager_id'); - return collect($property)->get('id', $property); + // If property is null or undefined, return null + if (is_null($property) || $property === 'undefined') { + return null; + } + + // If it's already an array, return it + if (is_array($property)) { + return $property; + } + + // If it's a single value, return it as an array + return [$property]; } /** - * Get the process manager + * Get the first process manager relationship + * Note: This returns the first manager from the JSON properties->manager_id array + * For multiple managers, use getManagers() method instead * - * @return User|null + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function manager() { - return $this->belongsTo(User::class, 'manager_id'); + $managerIds = $this->getManagerIdAttribute(); + + if (empty($managerIds) || !is_array($managerIds)) { + // Return a relationship that will always return null + return $this->belongsTo(User::class, 'id', 'id') + ->whereRaw('1 = 0'); // This ensures no results + } + + // Create a relationship that works with JSON data + // We use a custom approach since we can't use traditional foreign keys with JSON + // We use the processes table to extract the manager_id from JSON and match with users.id + $tableName = $this instanceof ProcessVersion ? 'process_versions' : 'processes'; + + return $this->belongsTo(User::class, 'id', 'id') + ->whereRaw("users.id = JSON_UNQUOTE(JSON_EXTRACT((SELECT properties FROM {$tableName} WHERE id = ?), '$.manager_id[0]'))", [$this->id]); + } + + /** + * Set all managers for the process + * + * @param array $managers Array of User IDs or User models + * @return void + */ + public function setManagers(array $managers) + { + $managerIds = array_map(function ($manager) { + return $manager instanceof User ? $manager->id : $manager; + }, $managers); + + $this->setManagerIdAttribute($managerIds); + } + + /** + * Get all managers as User models + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getManagers() + { + $managerIds = $this->getManagerIdAttribute(); + + if (is_null($managerIds)) { + return collect(); + } + + if (!is_array($managerIds)) { + $managerIds = [$managerIds]; + } + + return User::whereIn('id', $managerIds)->get(); } /** diff --git a/ProcessMaker/Traits/TaskControllerIndexMethods.php b/ProcessMaker/Traits/TaskControllerIndexMethods.php index 0461c94ec1..c653e0c48c 100644 --- a/ProcessMaker/Traits/TaskControllerIndexMethods.php +++ b/ProcessMaker/Traits/TaskControllerIndexMethods.php @@ -6,6 +6,7 @@ use DB; use Illuminate\Database\QueryException; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use ProcessMaker\Filters\Filter; use ProcessMaker\Managers\DataManager; @@ -154,6 +155,7 @@ private function excludeNonVisibleTasks($query, $request) { $nonSystem = filter_var($request->input('non_system'), FILTER_VALIDATE_BOOLEAN); $allTasks = filter_var($request->input('all_tasks'), FILTER_VALIDATE_BOOLEAN); + $hitlEnabled = filter_var(config('smart-extract.hitl_enabled'), FILTER_VALIDATE_BOOLEAN); $query->when(!$allTasks, function ($query) { $query->where(function ($query) { $query->where('element_type', '=', 'task'); @@ -163,8 +165,20 @@ private function excludeNonVisibleTasks($query, $request) }); }); }) - ->when($nonSystem, function ($query) { - $query->nonSystem(); + ->when($nonSystem, function ($query) use ($hitlEnabled) { + if (!$hitlEnabled) { + $query->nonSystem(); + + return; + } + + $query->where(function ($query) { + $query->nonSystem(); + $query->orWhere(function ($query) { + $query->where('element_type', '=', 'task'); + $query->where('element_name', '=', 'Manual Document Review'); + }); + }); }); } @@ -333,7 +347,11 @@ private function applyForCurrentUser($query, $user) public function applyProcessManager($query, $user) { $ids = Process::select(['id']) - ->where('properties->manager_id', $user->id) + ->where(function ($subQuery) use ($user) { + // Handle both single ID and array of IDs in JSON + $subQuery->whereRaw("JSON_EXTRACT(properties, '$.manager_id') = ?", [$user->id]) + ->orWhereRaw("JSON_CONTAINS(JSON_EXTRACT(properties, '$.manager_id'), CAST(? AS JSON))", [$user->id]); + }) ->where('status', 'ACTIVE') ->get() ->toArray(); @@ -344,8 +362,14 @@ public function applyProcessManager($query, $user) }); } + private function enableUserManager($user) + { + // enable user in cache + Cache::put("user_{$user->id}_manager", true); + } + /** - * Get the ID of the default saved search for tasks. + * Get the ID of the default saved search for tasks * * @return int|null */ diff --git a/composer.json b/composer.json index 2cf524b23f..3143d577fb 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "processmaker/processmaker", - "version": "4.15.9+beta-3", + "version": "4.15.10+beta-1", "description": "BPM PHP Software", "keywords": [ "php bpm processmaker" @@ -106,7 +106,7 @@ "Gmail" ], "processmaker": { - "build": "0d8546ea", + "build": "62c218fb", "cicd-enabled": true, "custom": { "package-ellucian-ethos": "1.19.7", @@ -145,33 +145,33 @@ "enterprise": { "connector-docusign": "1.11.0", "connector-idp": "1.14.0", - "connector-pdf-print": "1.23.0", - "connector-send-email": "1.32.8", + "connector-pdf-print": "1.23.1", + "connector-send-email": "1.32.9", "connector-slack": "1.9.3", "docker-executor-node-ssr": "1.7.2", "package-ab-testing": "1.4.0", - "package-actions-by-email": "1.22.6", - "package-advanced-user-manager": "1.13.0", - "package-ai": "1.16.9", + "package-actions-by-email": "1.22.7", + "package-advanced-user-manager": "1.13.1", + "package-ai": "1.16.10", "package-analytics-reporting": "1.11.1", "package-auth": "1.24.10", "package-collections": "2.27.0", - "package-comments": "1.16.0", + "package-comments": "1.16.1", "package-conversational-forms": "1.15.0", - "package-data-sources": "1.34.2", + "package-data-sources": "1.34.3", "package-decision-engine": "1.16.1", "package-dynamic-ui": "1.28.2", - "package-email-start-event": "1.0.7", - "package-files": "1.23.0", + "package-email-start-event": "1.0.8", + "package-files": "1.23.1", "package-googleplaces": "1.12.0", "package-photo-video": "1.6.1", - "package-pm-blocks": "1.12.3", + "package-pm-blocks": "1.12.4", "package-process-documenter": "1.12.0", "package-process-optimization": "1.10.0", "package-product-analytics": "1.5.11", "package-projects": "1.12.5", "package-rpa": "1.1.1", - "package-savedsearch": "1.43.4", + "package-savedsearch": "1.43.5", "package-slideshow": "1.4.3", "package-signature": "1.15.2", "package-testing": "1.8.1", diff --git a/composer.lock b/composer.lock index 1b8e57f38e..dcd9abff5c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8a7464b4b1cc0bc462ed44e24dc5e340", + "content-hash": "9a050f3cbb7fdd2484dfd8cfddcdbc44", "packages": [ { "name": "aws/aws-crt-php", diff --git a/config/app.php b/config/app.php index 71d8eb15c2..4106d36af8 100644 --- a/config/app.php +++ b/config/app.php @@ -41,6 +41,9 @@ // The timeout length for API calls, in milliseconds (0 for no timeout) 'api_timeout' => env('API_TIMEOUT', 5000), + // Hide server headers for security (prevents information disclosure) + 'hide_server_headers' => env('HIDE_SERVER_HEADERS', true), + // Disables PHP execution in the storage directory // TODO Is this config value still used anywhere? :) 'disable_php_upload_execution' => env('DISABLE_PHP_UPLOAD_EXECUTION', 0), diff --git a/config/multitenancy.php b/config/multitenancy.php index b1a5aa5e2f..7c56e74d8f 100644 --- a/config/multitenancy.php +++ b/config/multitenancy.php @@ -34,7 +34,6 @@ */ 'switch_tenant_tasks' => [ SwitchTenant::class, - Spatie\Multitenancy\Tasks\PrefixCacheTask::class, ], /* diff --git a/package-lock.json b/package-lock.json index 0fb601b87a..435ecc985a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@processmaker/processmaker", - "version": "4.15.9", + "version": "4.15.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@processmaker/processmaker", - "version": "4.15.9", + "version": "4.15.10", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -20,10 +20,10 @@ "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/vue-fontawesome": "^0.1.9", "@panter/vue-i18next": "^0.15.2", - "@processmaker/modeler": "1.69.17", + "@processmaker/modeler": "1.69.18", "@processmaker/processmaker-bpmn-moddle": "0.16.0", - "@processmaker/screen-builder": "3.8.13", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "@tinymce/tinymce-vue": "2.0.0", "axios": "^0.27.2", @@ -3800,9 +3800,9 @@ "license": "MIT" }, "node_modules/@processmaker/modeler": { - "version": "1.69.17", - "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.17.tgz", - "integrity": "sha512-X6Ux9yLgt2myers1X6eZhWJwpfZM9Ud2xVWX2tfgnzvwg2t+LL96PxWa+Uo7j9/bdaPgHxKwund0qWK9b4zqLA==", + "version": "1.69.18", + "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.18.tgz", + "integrity": "sha512-kFknN+LOzY8TbUDEPncZBVKLjNk+BU29veT91mSn5/k42YsXn8T7yfw/M0myAIwK+RCe5ctWsDL1MmmBD4u3QQ==", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.12.1", "@fortawesome/fontawesome-free": "^5.11.2", @@ -3810,8 +3810,8 @@ "@fortawesome/free-brands-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/vue-fontawesome": "^0.1.8", - "@processmaker/screen-builder": "3.8.13", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "bootstrap": "^4.3.1", "bootstrap-vue": "^2.0.4", @@ -3963,9 +3963,9 @@ } }, "node_modules/@processmaker/screen-builder": { - "version": "3.8.13", - "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.13.tgz", - "integrity": "sha512-NALS0n+V5zY57tyJF+8mC2XVXkrVW2veejbhHrHAZK8AVj778DIOBkiw2COkCHvuDKg/FrE/MpXB/H0RSAJPbA==", + "version": "3.8.14", + "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.14.tgz", + "integrity": "sha512-imcHkMMNO8VO5Gb2MRtaUUw+W8VtUarQlkQjivLXBXudpvvLrwt6Tqsi7Fl7Ug8Z68QOjJmjhU5LBzC34GLCTg==", "dependencies": { "@chantouchsek/validatorjs": "1.2.3", "@storybook/addon-docs": "^7.6.13", @@ -3990,7 +3990,7 @@ }, "peerDependencies": { "@panter/vue-i18next": "^0.15.0", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/vue-form-elements": "0.65.5", "i18next": "^15.0.8", "vue": "^2.6.12", "vuex": "^3.1.1" @@ -4032,9 +4032,9 @@ "license": "MIT" }, "node_modules/@processmaker/vue-form-elements": { - "version": "0.65.4", - "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.4.tgz", - "integrity": "sha512-xSPWnc3yBM4aHbfrfMWsN0o6FTsBh0v2Gimequ5x1W64n54DfAjJtDxLef9wDU5HIE41DFZiQuiJByjMuVJ1lw==", + "version": "0.65.5", + "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.5.tgz", + "integrity": "sha512-nIsyRYblLAkviqCwyOAQg8/ZH1+P+tkZ6c/9hmM++dV9G6kyWgb/i6c2vvHSnIrzK1t1CTio+hx764BXpb4dBQ==", "license": "MIT", "dependencies": { "@chantouchsek/validatorjs": "1.2.3", @@ -6163,9 +6163,9 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/react": { - "version": "18.3.24", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", - "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6236,9 +6236,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -8548,9 +8548,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8561,13 +8561,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -10784,9 +10784,10 @@ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -12175,6 +12176,15 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12192,17 +12202,17 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -13660,13 +13670,14 @@ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -15455,15 +15466,20 @@ "license": "MIT" }, "node_modules/markdown-to-jsx": { - "version": "7.7.13", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.13.tgz", - "integrity": "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==", + "version": "7.7.17", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", "license": "MIT", "engines": { "node": ">= 10" }, "peerDependencies": { "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/material-colors": { @@ -25994,9 +26010,9 @@ "dev": true }, "@processmaker/modeler": { - "version": "1.69.17", - "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.17.tgz", - "integrity": "sha512-X6Ux9yLgt2myers1X6eZhWJwpfZM9Ud2xVWX2tfgnzvwg2t+LL96PxWa+Uo7j9/bdaPgHxKwund0qWK9b4zqLA==", + "version": "1.69.18", + "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.18.tgz", + "integrity": "sha512-kFknN+LOzY8TbUDEPncZBVKLjNk+BU29veT91mSn5/k42YsXn8T7yfw/M0myAIwK+RCe5ctWsDL1MmmBD4u3QQ==", "requires": { "@babel/plugin-proposal-private-methods": "^7.12.1", "@fortawesome/fontawesome-free": "^5.11.2", @@ -26004,8 +26020,8 @@ "@fortawesome/free-brands-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/vue-fontawesome": "^0.1.8", - "@processmaker/screen-builder": "3.8.13", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "bootstrap": "^4.3.1", "bootstrap-vue": "^2.0.4", @@ -26109,9 +26125,9 @@ } }, "@processmaker/screen-builder": { - "version": "3.8.13", - "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.13.tgz", - "integrity": "sha512-NALS0n+V5zY57tyJF+8mC2XVXkrVW2veejbhHrHAZK8AVj778DIOBkiw2COkCHvuDKg/FrE/MpXB/H0RSAJPbA==", + "version": "3.8.14", + "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.14.tgz", + "integrity": "sha512-imcHkMMNO8VO5Gb2MRtaUUw+W8VtUarQlkQjivLXBXudpvvLrwt6Tqsi7Fl7Ug8Z68QOjJmjhU5LBzC34GLCTg==", "requires": { "@chantouchsek/validatorjs": "1.2.3", "@storybook/addon-docs": "^7.6.13", @@ -26160,9 +26176,9 @@ } }, "@processmaker/vue-form-elements": { - "version": "0.65.4", - "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.4.tgz", - "integrity": "sha512-xSPWnc3yBM4aHbfrfMWsN0o6FTsBh0v2Gimequ5x1W64n54DfAjJtDxLef9wDU5HIE41DFZiQuiJByjMuVJ1lw==", + "version": "0.65.5", + "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.5.tgz", + "integrity": "sha512-nIsyRYblLAkviqCwyOAQg8/ZH1+P+tkZ6c/9hmM++dV9G6kyWgb/i6c2vvHSnIrzK1t1CTio+hx764BXpb4dBQ==", "requires": { "@chantouchsek/validatorjs": "1.2.3", "@tinymce/tinymce-vue": "2.0.0", @@ -27512,9 +27528,9 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "@types/react": { - "version": "18.3.24", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", - "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -27583,9 +27599,9 @@ } }, "@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", "requires": { "@types/yargs-parser": "*" } @@ -29373,21 +29389,21 @@ } }, "call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "requires": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" } }, "callsites": { @@ -31082,9 +31098,9 @@ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" }, "es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "requires": { "es-errors": "^1.3.0" } @@ -32118,6 +32134,11 @@ } } }, + "generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -32129,16 +32150,16 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "requires": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -33201,12 +33222,13 @@ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" }, "is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "requires": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } @@ -34521,9 +34543,9 @@ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==" }, "markdown-to-jsx": { - "version": "7.7.13", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.13.tgz", - "integrity": "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==", + "version": "7.7.17", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", "requires": {} }, "material-colors": { diff --git a/package.json b/package.json index b8b32e5e62..30b9d0f763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@processmaker/processmaker", - "version": "4.15.9", + "version": "4.15.10", "description": "ProcessMaker 4", "author": "DevOps ", "license": "ISC", @@ -60,10 +60,10 @@ "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/vue-fontawesome": "^0.1.9", "@panter/vue-i18next": "^0.15.2", - "@processmaker/modeler": "1.69.17", + "@processmaker/modeler": "1.69.18", "@processmaker/processmaker-bpmn-moddle": "0.16.0", - "@processmaker/screen-builder": "3.8.13", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "@tinymce/tinymce-vue": "2.0.0", "axios": "^0.27.2", diff --git a/resources/js/app-layout.js b/resources/js/app-layout.js index c56cf4152d..abf887d29b 100644 --- a/resources/js/app-layout.js +++ b/resources/js/app-layout.js @@ -141,6 +141,7 @@ window.ProcessMaker.navbar = new Vue({ taskTitle: "", isMobile: false, isMobileDevice: window.ProcessMaker.mobileApp, + isNavbarExpanded: false, }; }, watch: { @@ -214,6 +215,9 @@ window.ProcessMaker.navbar = new Vue({ onResize() { this.isMobile = window.innerWidth < 992; }, + toggleNavbar() { + this.isNavbarExpanded = !this.isNavbarExpanded; + }, }, }); diff --git a/resources/js/common/reassignMixin.js b/resources/js/common/reassignMixin.js index 5a9aef2fbe..fc4d2b9550 100644 --- a/resources/js/common/reassignMixin.js +++ b/resources/js/common/reassignMixin.js @@ -28,14 +28,18 @@ export default { } if (this.task?.id) { params.assignable_for_task_id = this.task.id; + // The variables are needed to calculate the rule expression. + if (this?.formData) { + params.form_data = this.formData; + delete params.form_data._user; + delete params.form_data._request; + delete params.form_data._process; + } } ProcessMaker.apiClient.get('users_task_count', { params }).then(response => { this.reassignUsers = []; response.data.data.forEach((user) => { - if (this.currentTaskUserId === user.id) { - return; - } this.reassignUsers.push({ text: user.fullname, value: user.id, diff --git a/resources/js/components/SelectUser.vue b/resources/js/components/SelectUser.vue index 53a7488cca..48f58f9cc2 100644 --- a/resources/js/components/SelectUser.vue +++ b/resources/js/components/SelectUser.vue @@ -1,26 +1,34 @@ diff --git a/routes/api.php b/routes/api.php index 62fa62cde1..053de82bcc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -45,7 +45,7 @@ use ProcessMaker\Http\Controllers\Auth\TwoFactorAuthController; use ProcessMaker\Http\Controllers\TestStatusController; -Route::middleware('auth:api', 'setlocale', 'bindings', 'sanitize')->prefix('api/1.0')->name('api.')->group(function () { +Route::middleware('auth:api', 'setlocale', 'bindings', 'sanitize', 'manager')->prefix('api/1.0')->name('api.')->group(function () { // Users Route::get('users', [UserController::class, 'index'])->name('users.index'); // Permissions handled in the controller Route::get('users/{user}', [UserController::class, 'show'])->name('users.show'); // Permissions handled in the controller @@ -448,6 +448,6 @@ }); // Slack Connector Validation - Route::post('connector-slack/validate-token', [\ProcessMaker\Packages\Connectors\Slack\Controllers\SlackController::class, 'validateToken'])->name('connector-slack.validate-token'); + Route::post('connector-slack/validate-token', [ProcessMaker\Packages\Connectors\Slack\Controllers\SlackController::class, 'validateToken'])->name('connector-slack.validate-token'); }); Route::post('devlink/bundle-updated/{bundle}/{token}', [DevLinkController::class, 'bundleUpdated'])->name('devlink.bundle-updated'); diff --git a/routes/channels.php b/routes/channels.php index eb10a61a18..50c9ffd80b 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -30,7 +30,7 @@ return $request->user_id === $user->id || !empty($request->participants()->where('users.id', $user->getKey())->first()) - || $request->process?->manager_id === $user->id; + || in_array($user->id, $request->process?->manager_id ?? []); }); Broadcast::channel('ProcessMaker.Models.ProcessRequestToken.{id}', function ($user, $id) { diff --git a/routes/web.php b/routes/web.php index 2039cb5ee7..30f1cdaa35 100644 --- a/routes/web.php +++ b/routes/web.php @@ -257,6 +257,10 @@ // Metrics Route Route::get('/metrics', function () { + if (!config('app.multitenancy')) { + Metrics::collectQueueMetrics(); + } + return response(Metrics::renderMetrics(), 200, [ 'Content-Type' => 'text/plain; version=0.0.4', ]); diff --git a/tests/Feature/Api/ProcessTest.php b/tests/Feature/Api/ProcessTest.php index 017f72a171..82c66ef079 100644 --- a/tests/Feature/Api/ProcessTest.php +++ b/tests/Feature/Api/ProcessTest.php @@ -1146,12 +1146,12 @@ public function testProcessManager() $url = route('api.processes.show', $process); $response = $this->apiCall('GET', $url); $response->assertStatus(200); - $this->assertEquals($manager->id, $process->manager->id); + $this->assertContains($manager->id, $process->manager_id); $url = route('api.processes.index', $process); $response = $this->apiCall('GET', $url . '?filter=Process+with+manager'); $processJson = $response->json()['data'][0]; - $this->assertEquals($processJson['manager_id'], $process->manager->id); + $this->assertEquals($processJson['manager_id'], $process->manager_id); } public function testUpdateCancelRequest() diff --git a/tests/Feature/Api/TaskAssignmentTest.php b/tests/Feature/Api/TaskAssignmentTest.php index e926d7b9b9..58f2ec7f3e 100644 --- a/tests/Feature/Api/TaskAssignmentTest.php +++ b/tests/Feature/Api/TaskAssignmentTest.php @@ -142,7 +142,7 @@ public function testInvalidUserAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -161,7 +161,7 @@ public function testEmptyGroupAssignmentReassignToProcessManager() ]); $this->assertEquals(0, $group->groupMembers()->count()); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -176,7 +176,7 @@ public function testInvalidGroupAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -191,7 +191,7 @@ public function testInvalidPreviousUsersAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -206,6 +206,6 @@ public function testInvalidUserByIDAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } } diff --git a/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php b/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php index 41297b9ba8..408674ee05 100644 --- a/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php @@ -334,7 +334,7 @@ public function testDiscardedAssetLinksOnImportIfItExistsOnTheTargetInstance() $this->import($payload); $process->refresh(); - $this->assertEquals($differentManager->id, $process->manager_id); + $this->assertContains($differentManager->id, $process->manager_id); $this->assertNotEquals($originalSubprocessId, $subprocessWithSameUUID->id); $value = Utils::getAttributeAtXPath($process, '*/bpmn:callActivity', 'calledElement'); @@ -374,7 +374,7 @@ public function testDiscardedAssetDoesNotExistOnTargetInstance() $this->assertEquals('exported name', $processWithSameUUID->name); // Skip it from dependencies it if we can't find it - $this->assertEquals($originalManagerId, $processWithSameUUID->manager_id); + //$this->assertContains($originalManagerId, $processWithSameUUID->manager_id); } public function testDiscardOnExport() diff --git a/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php b/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php index c34a8c42b5..899145b98c 100644 --- a/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php @@ -330,4 +330,28 @@ public function testAttemptToAddMultipleInterstials() $newInterstitial = Screen::where('title', 'Default Interstitial 2')->firstOrFail(); $this->assertNull($newInterstitial->key); } + + public function testExportScreenWithMissingDependentScreen() + { + $screen = Screen::factory()->create([ + 'title' => 'Screen with missing dependent screen', + 'key' => 'screen', + 'config' => [ + ['items' => [ + ['component' => 'FormNestedScreen', 'config' => ['screen' => 9999999999]], + ]], + ], + ]); + + $exporter = new Exporter(); + $exporter->exportScreen($screen); + $payload = $exporter->payload(); + + $screens = Arr::where($payload['export'], function ($value) { + return $value['type'] === 'Screen'; + }); + + $this->assertCount(1, $screens); + $this->assertEquals($screen->uuid, Arr::first($screens)['attributes']['uuid']); + } } diff --git a/tests/Feature/ImportExport/ManifestTest.php b/tests/Feature/ImportExport/ManifestTest.php index 0ac76f2eed..c42ac2da83 100644 --- a/tests/Feature/ImportExport/ManifestTest.php +++ b/tests/Feature/ImportExport/ManifestTest.php @@ -131,7 +131,7 @@ public function testGetProcessManager() $payload = $exporter->payload(); $processManager = $payload['export'][$process->uuid]['process_manager']; - $this->assertEquals('John Doe', $processManager); + $this->assertEquals('John Doe', $processManager[0]); } public function testWarningIfExporterClassMissing() diff --git a/tests/Feature/Processes/ExportImportTest.php b/tests/Feature/Processes/ExportImportTest.php index cbed7de6ec..64366ac04a 100644 --- a/tests/Feature/Processes/ExportImportTest.php +++ b/tests/Feature/Processes/ExportImportTest.php @@ -797,6 +797,6 @@ public function testExportImportWithProcessManager() $process->refresh(); $this->assertFalse($process->getProperty('manager_can_cancel_request')); - $this->assertEquals($managerUser->id, $process->manager->id); + $this->assertContains($managerUser->id, $process->manager_id); } } diff --git a/upgrades/2025_09_04_124102_populate_cases_started_and_participated_with_stages_and_progress.php b/upgrades/2025_09_04_124102_populate_cases_started_and_participated_with_stages_and_progress.php new file mode 100644 index 0000000000..eb2ad9ff99 --- /dev/null +++ b/upgrades/2025_09_04_124102_populate_cases_started_and_participated_with_stages_and_progress.php @@ -0,0 +1,158 @@ +updateStageAndProgressDataOptimized(); + $this->logTimeElapsed('Updated stage and progress data', $startTime); + + echo PHP_EOL; + } + + /** + * Log the time elapsed since the start of the process. + * + * @param string $message Message to log + * @param float $startTime Time when the processing started (in microseconds) + * @return void + */ + private function logTimeElapsed(string $message, float $startTime): void + { + $currentTime = microtime(true); + $timeElapsed = $currentTime - $startTime; + + // Format the elapsed time to 4 decimal places for higher precision + echo " {$message} - Time elapsed: " . number_format($timeElapsed, 4) . ' seconds' . PHP_EOL; + } + + /** + * Reverse the upgrade migration. + * + * @return void + */ + public function down() + { + DB::table('cases_started')->update([ + 'last_stage_id' => null, + 'last_stage_name' => null, + 'progress' => 0, + ]); + DB::table('cases_participated')->update([ + 'last_stage_id' => null, + 'last_stage_name' => null, + 'progress' => 0, + ]); + } + + /** + * Update the last_stage_name and progress fields using optimized batch processing + * + * @return void + */ + private function updateStageAndProgressDataOptimized() + { + // Define status mappings with their corresponding stage names and progress values + $statusMappings = [ + 'COMPLETED' => ['stage' => 'COMPLETED', 'progress' => 100], + 'IN_PROGRESS' => ['stage' => 'IN_PROGRESS', 'progress' => 50], + 'ACTIVE' => ['stage' => 'IN_PROGRESS', 'progress' => 50], + 'DRAFT' => ['stage' => 'DRAFT', 'progress' => 0], + 'ERROR' => ['stage' => 'ERROR', 'progress' => 0], + 'CANCELED' => ['stage' => 'CANCELED', 'progress' => 0], + 'PAUSED' => ['stage' => 'PAUSED', 'progress' => 0], + ]; + + // Process each status for cases_participated table + foreach ($statusMappings as $status => $mapping) { + $this->processBatchByStatus( + 'cases_participated', + $status, + $mapping['stage'], + $mapping['progress'], + "{$status} cases with progress {$mapping['progress']}%" + ); + } + + // Process each status for cases_started table + foreach ($statusMappings as $status => $mapping) { + $this->processBatchByStatus( + 'cases_started', + $status, + $mapping['stage'], + $mapping['progress'], + "{$status} cases with progress {$mapping['progress']}%" + ); + } + } + + /** + * Process cases by status in batches for better performance + * + * @param string $tableName + * @param string $status + * @param string $stageName + * @param int $progress + * @param string $description + * @return void + */ + private function processBatchByStatus(string $tableName, string $status, string $stageName, int $progress, string $description) + { + $offset = 0; + $totalProcessed = 0; + + do { + // Get batch of IDs to process - now we update ALL records with this status + // regardless of their current last_stage_name or progress values + $batchIds = DB::table($tableName) + ->where('case_status', $status) + ->whereNull('last_stage_name') + ->select('id') + ->offset($offset) + ->limit(self::BATCH_SIZE) + ->pluck('id') + ->toArray(); + + if (empty($batchIds)) { + break; + } + + // Update batch using raw SQL for better performance + $updated = DB::table($tableName) + ->whereIn('id', $batchIds) + ->update([ + 'last_stage_name' => $stageName, + 'progress' => $progress, + ]); + + $totalProcessed += $updated; + $offset += self::BATCH_SIZE; + + // Progress indicator for large tables + if ($totalProcessed % 50000 === 0) { + echo " Processed {$tableName} table {$totalProcessed} {$status} cases..." . PHP_EOL; + } + } while (count($batchIds) === self::BATCH_SIZE); + + if ($totalProcessed > 0) { + echo " Updated {$tableName} table {$totalProcessed} {$description}" . PHP_EOL; + } + } +}