diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index 1578e8df17..5824a81bd3 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -129,17 +129,21 @@ public function index(Request $request) $user = Auth::user(); $orderBy = $this->getRequestSortBy($request, 'name'); + if (!str_contains($orderBy[0], '.')) { + $orderBy[0] = 'processes.' . $orderBy[0]; + } $perPage = $this->getPerPage($request); $include = $this->getRequestInclude($request); + $relationships = array_values(array_diff($include, ['events'])); $status = $request->input('status'); $pmql = $request->input('pmql', ''); - $processes = Process::nonSystem()->notArchived()->with($include); + $processes = Process::nonSystem()->notArchived()->with($relationships); if ($status === 'archived') { - $processes = Process::archived()->with($include); + $processes = Process::archived()->with($relationships); } if ($status === 'all') { - $processes = Process::active()->with($include); + $processes = Process::active()->with($relationships); } $filter = $request->input('filter', ''); @@ -188,60 +192,29 @@ public function index(Request $request) // Get with launchpad $launchpad = $request->input('launchpad', false); - $processes = $processes->with('events') - ->select('processes.*') + $processes = $processes + ->with('notification_settings') + ->exclude(['bpmn', 'svg']) ->leftJoin(\DB::raw('(select id, uuid, name from process_categories) as category'), 'processes.process_category_id', '=', 'category.id') ->leftJoin(\DB::raw('(select id, uuid, username, lastname, firstname from users) as user'), 'processes.user_id', '=', 'user.id') ->orderBy(...$orderBy) - ->get() - ->collect(); - - foreach ($processes as $key => $process) { - // filter the start events that can be used manually (no timer start events); - // TODO: startEvents is not a real property on Process. - // Move below to $process->getManualStartEvents(); - $process->startEvents = $process->events->filter(function ($event) { - $eventIsTimerStart = collect($event['eventDefinitions']) - ->filter(function ($eventDefinition) { - return $eventDefinition['$type'] == 'timerEventDefinition'; - })->count() > 0; - - // Filter out web entry start events and email start events - $eventIsWebEntry = false; - $eventIsEmailStart = false; - if (isset($event['config'])) { - $config = json_decode($event['config'], true); - if (isset($config['web_entry']) && $config['web_entry'] !== null) { - $eventIsWebEntry = true; - } elseif (isset($config['email_start']) && $config['email_start'] !== null) { - $eventIsEmailStart = true; - } - } + ->paginate($perPage); - return !$eventIsTimerStart && !$eventIsWebEntry && !$eventIsEmailStart; - })->values(); + foreach ($processes as $process) { + $process->setAppends(array_values(array_diff($process->getAppends(), ['projects']))); + $process->makeHidden('notification_settings'); - // Get the id bookmark related - $process->bookmark_id = Bookmark::getBookmarked($bookmark, $process->id, $user->id); - // Get the launchpad configuration - $process->launchpad = ProcessLaunchpad::getLaunchpad($launchpad, $process->id); + $process->bookmark_id = $bookmark + ? Bookmark::getBookmarked($bookmark, $process->id, $user->id) + : 0; + $process->launchpad = $launchpad + ? ProcessLaunchpad::getLaunchpad($launchpad, $process->id) + : null; $process->case_retention_tier_adjustment_notice = false; if ($user->is_administrator && config('app.case_retention_policy_enabled')) { $process->case_retention_tier_adjustment_notice = CaseRetentionTierService::adjustmentNoticeIsActive($process); } - - // Filter all processes that have event definitions (start events like message event, conditional event, signal event, timer event) - if ($request->has('without_event_definitions') && $request->input('without_event_definitions') == 'true') { - $startEvents = $process->events->filter(function ($event) { - return collect($event['eventDefinitions'])->isEmpty(); - }); - } - - // filter only valid executable processes - if (!$process->isValidForExecution()) { - $processes->startEvents = []; - } } return new ProcessCollection($processes); diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index 4cabf9cb34..56d0f0b7c9 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php @@ -37,7 +37,7 @@ use ProcessMaker\Nayra\Contracts\Bpmn\CatchEventInterface; use ProcessMaker\Notifications\ProcessCanceledNotification; use ProcessMaker\Query\SyntaxError; -use ProcessMaker\RetryProcessRequest; +use ProcessMaker\Repositories\ProcessRequestListingRawRepository; use ProcessMaker\Traits\ProcessMapTrait; use Symfony\Component\HttpFoundation\IpUtils; use Throwable; @@ -117,7 +117,7 @@ public function index(Request $request, $getTotal = false, User $user = null) $query = ProcessRequest::forUser($user); $includes = $request->input('include', ''); foreach (array_filter(explode(',', $includes)) as $include) { - if (in_array($include, ProcessRequest::$allowedIncludes)) { + if (in_array($include, ProcessRequest::$allowedIncludes) && $include !== 'process') { $query->with($include); } } @@ -176,6 +176,7 @@ public function index(Request $request, $getTotal = false, User $user = null) ->withAggregate('processVersion', 'alternative') ->paginate($request->input('per_page', 10)); $total = $response->total(); + app(ProcessRequestListingRawRepository::class)->hydrate($response, $includes); } } catch (QueryException $e) { throw $e; @@ -900,7 +901,14 @@ public function getRequestsByCase(Request $request, User $user = null) // Apply ordering only if a valid order_by field is provided $query->applyOrdering($request); - $response = $query->applyPagination($request); + $response = $query + ->select('process_requests.*') + ->withAggregate('processVersion', 'alternative') + ->applyPagination($request); + app(ProcessRequestListingRawRepository::class)->hydrate( + $response, + $request->input('include', '') + ); // Get activeTasks and participants $response = $response->map(function ($processRequest) use ($request) { diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index d82a30dd88..1c872b56c8 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -16,6 +16,7 @@ use ProcessMaker\Events\ActivityReassignment; use ProcessMaker\Facades\WorkflowManager; use ProcessMaker\Filters\Filter; +use ProcessMaker\Http\Controllers\Api\V1_1\TaskController as V1_1TaskController; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Http\Resources\ApiResource; use ProcessMaker\Http\Resources\Task as Resource; @@ -134,6 +135,9 @@ private function processExecutionRaw(): ProcessExecutionRawRepository */ public function index(Request $request, $getTotal = false, User $user = null) { + if (config('app.processmaker_optimized_tasks_enabled')) { + return (new V1_1TaskController())->indexOptimized($request, $getTotal, $user); + } // If a specific user is specified, use it; otherwise use the authorized user // This is necessary to produce accurate counts for Saved Searches if (!$user) { diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php index 334c109077..bc0976d0e2 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php @@ -4,20 +4,41 @@ namespace ProcessMaker\Http\Controllers\Api\V1_1; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use ProcessMaker\Cache\Screens\ScreenCacheFactory; use ProcessMaker\Http\Controllers\Controller; +use ProcessMaker\Http\Resources\TaskCollection; use ProcessMaker\Http\Resources\V1_1\TaskInterstitialResource; use ProcessMaker\Http\Resources\V1_1\TaskResource; use ProcessMaker\Http\Resources\V1_1\TaskScreen; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\User; use ProcessMaker\ProcessTranslations\TranslationManager; +use ProcessMaker\Traits\TaskControllerIndexMethods; +use ProcessMaker\Services\TaskCompletionRawService; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + class TaskController extends Controller { + use TaskControllerIndexMethods; + + public function __construct( + private readonly TaskCompletionRawService $taskCompletionRawService, + ) { + } + + public $doNotSanitize = [ + 'data', + 'pmql', + ]; + protected $defaultFields = [ 'id', 'element_id', @@ -29,6 +50,56 @@ class TaskController extends Controller 'process_request_id', ]; + public function indexOptimized(Request $request, $getTotal = false, ?User $user = null) + { + if (!$user) { + $user = Auth::user(); + } + + $request->merge(['optimized' => true]); + + $query = $this->indexOptimizedBaseQuery($request); + $this->applyIndexFieldSelection($query, $request); + $this->applyFilters($query, $request); + $this->excludeNonVisibleTasks($query, $request); + $this->applyColumnOrdering($query, $request); + $this->applyStatusFilter($query, $request); + + if ($request->input('processesIManage') === 'true') { + $this->applyProcessManager($query, $user, $request); + } else { + $this->applyForCurrentUser($query, $user); + } + + $this->applyPmql($query, $request, $user); + $this->applyAdvancedFilter($query, $request); + $query->overdue($request->input('overdue')); + + if ($getTotal === true) { + return $query->count(); + } + + try { + $response = $query->paginate($request->input('per_page', 10)); + } catch (QueryException $e) { + return $this->handleQueryException($e); + } + + $response = $this->applyUserFilter($response, $request, $user); + + if ($response->total() > 0 && $request->input('processesIManage') === 'true') { + $this->enableUserManager($user); + } + + $inOverdueQuery = ProcessRequestToken::query() + ->whereIn('id', $response->pluck('id')) + ->where('due_at', '<', Carbon::now()); + + $response->inOverdue = $inOverdueQuery->count(); + + return new TaskCollection($response); + } + /** * Display a listing of the resource. */ @@ -151,4 +222,45 @@ public function showInterstitial($taskId) return $response; } + + private function handleQueryException(QueryException $e) + { + $regex = '~Column not found: 1054 Unknown column \'(.*?)\' in \'where clause\'~'; + + preg_match($regex, $e->getMessage(), $m); + + $message = __('PMQL Is Invalid.'); + + if (count($m) > 1) { + $message .= ' ' . __('Column not found: ') . '"' . $m[1] . '"'; + } + + \Log::error($e->getMessage()); + + return response([ + 'message' => $message, + ], 422); + } + + /** + * Complete a task using the raw-query optimized path. + */ + public function update(Request $request, int $taskId) + { + if ($request->input('status') !== 'COMPLETED') { + abort(422, __('Only task completion is supported on this endpoint. Use PUT /api/1.0/tasks/{id} for other updates.')); + } + + try { + $task = $this->taskCompletionRawService->completeTask( + $taskId, + json_optimize_decode($request->getContent(), true) ?: [], + $request->user(), + ); + } catch (NotFoundHttpException $exception) { + return response()->json(['message' => $exception->getMessage()], 404); + } + + return response()->json($task); + } } diff --git a/ProcessMaker/Http/Controllers/TaskController.php b/ProcessMaker/Http/Controllers/TaskController.php index d7b658b451..e238b88701 100755 --- a/ProcessMaker/Http/Controllers/TaskController.php +++ b/ProcessMaker/Http/Controllers/TaskController.php @@ -113,13 +113,16 @@ public function edit(ProcessRequestToken $task, string $preview = '') { $task = $task->loadTokenInstance(); $dataManager = new DataManager(); - $userHasComments = Comment::where('commentable_type', ProcessRequestToken::class) - ->where('commentable_id', $task->id) - ->where('body', 'like', '%{{' . \Auth::user()->id . '}}%') - ->count() > 0; - if (!\Auth::user()->can('update', $task) && !$userHasComments) { - $this->authorize('update', $task); + if (!\Auth::user()->can('update', $task)) { + $userHasComments = Comment::where('commentable_type', ProcessRequestToken::class) + ->where('commentable_id', $task->id) + ->where('body', 'like', '%{{' . \Auth::user()->id . '}}%') + ->count() > 0; + + if (!$userHasComments) { + $this->authorize('update', $task); + } } //Mark notification as read @@ -183,7 +186,9 @@ public function edit(ProcessRequestToken $task, string $preview = '') ]); } - UserResourceView::setViewed(Auth::user(), $task); + dispatch(function () use ($task) { + UserResourceView::setViewed(Auth::user(), $task); + })->afterResponse(); $currentUser = Auth::user()->only([ 'id', 'username', @@ -194,11 +199,13 @@ public function edit(ProcessRequestToken $task, string $preview = '') 'timezone', 'datetime_format', ]); - $userConfiguration = (new UserConfigurationController())->index(); + $userConfiguration = app(UserConfigurationController::class)->index(); [$hitlEnabled, $iframeSrc] = $this->smartExtractHitlConfiguration($task, $isSmartExtractTask); + $canUpdateTask = Auth::user()->can('update', $task); return view('tasks.edit', [ 'task' => $task, + 'canUpdateTask' => $canUpdateTask, 'dueLabels' => self::$dueLabels, 'manager' => $manager, 'submitUrl' => $submitUrl, diff --git a/ProcessMaker/Http/Middleware/BrowserCache.php b/ProcessMaker/Http/Middleware/BrowserCache.php index 4f632f86f2..39ebe90065 100644 --- a/ProcessMaker/Http/Middleware/BrowserCache.php +++ b/ProcessMaker/Http/Middleware/BrowserCache.php @@ -23,6 +23,10 @@ public function handle($request, Closure $next) return $response; } + if ($response->headers->has('ETag')) { + return $response; + } + $response->header('pragma', 'no-cache'); $response->header('Cache-Control', 'no-store'); diff --git a/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php new file mode 100644 index 0000000000..4a43e3ed4d --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php @@ -0,0 +1,77 @@ +isMethod('GET') && !$request->isMethod('HEAD'))) { + return $next($request); + } + + $etag = $this->tasksPageEtag->getEtag($request); + + if ($this->buildResponseWithEtag($etag)->isNotModified($request)) { + return $this->withPrivateCacheHeaders($this->buildNotModifiedResponse($etag, $request)); + } + + $response = $next($request); + $response->setEtag($etag); + + return $this->withPrivateCacheHeaders($response); + } + + /** + * Build a framework-compatible 304 response for a matched Tasks page ETag. + */ + private function buildNotModifiedResponse(string $etag, Request $request): Response + { + $response = $this->buildResponseWithEtag($etag); + $response->isNotModified($request); + + return $response; + } + + /** + * Create a response carrying the Tasks page validator. + * + * Weak ETags are used because the HTML may be transformed by gzip while the + * rendered representation remains equivalent for browser revalidation. + */ + private function buildResponseWithEtag(string $etag): Response + { + $response = new Response(); + $response->setEtag($etag, true); + + return $response; + } + + /** + * Apply browser-cache headers that allow private conditional revalidation. + */ + private function withPrivateCacheHeaders(Response $response): Response + { + $response->headers->set('Cache-Control', 'private, must-revalidate'); + $response->headers->remove('Pragma'); + $response->headers->remove('Expires'); + + return $response; + } +} diff --git a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php new file mode 100644 index 0000000000..298f8d0b81 --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php @@ -0,0 +1,373 @@ +hashAlgorithm(), json_encode($this->payload($request))) . '"'; + } + + /** + * Build the complete content-affecting context used to validate the page. + * + * Volatile values such as CSRF tokens, session ids, and randomized asset URLs are + * intentionally excluded because they would prevent useful conditional requests. + */ + private function payload(Request $request): array + { + $user = $request->user(); + + return [ + 'route' => [ + 'name' => $request->route()?->getName(), + 'path' => $request->path(), + 'router' => $request->route('router'), + 'query' => $this->sorted($request->query()), + ], + 'user' => $this->userPayload($user), + 'tenant' => $this->tenantPayload(), + 'permissions_v' => $this->permissionsVersion($user), + 'session_content' => [ + 'alert' => session('_alert'), + 'rememberme' => session('rememberme'), + ], + 'saved_search_v' => $this->savedSearchPayload($user), + 'locale' => app()->getLocale(), + 'task_context' => [ + 'user_filter' => $user ? SaveSession::getConfigFilter('taskFilter', $user) : null, + 'user_configuration' => $this->userConfigurationPayload($user), + 'task_drafts_enabled' => TaskDraft::draftsEnabled(), + ], + 'features_v' => $this->featuresPayload(), + 'packages_v' => $this->packagesPayload(), + ]; + } + + /** + * Capture user fields emitted into the layout or used by Tasks page decisions. + */ + private function userPayload(?User $user): ?array + { + if (!$user) { + return null; + } + + return [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'updated_at' => $this->dateValue($user->updated_at), + 'is_administrator' => $user->is_administrator, + 'status' => $user->status, + 'fullname' => $user->fullname, + 'avatar' => $user->avatar, + 'datetime_format' => $user->datetime_format, + 'timezone' => $user->timezone, + 'language' => $user->language, + ]; + } + + /** + * Include tenant identity because tenant config and assets can change the page shell. + */ + private function tenantPayload(): ?array + { + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; + + if (!$tenant) { + return null; + } + + return [ + 'id' => $tenant->id ?? null, + 'updated_at' => $this->dateValue($tenant->updated_at ?? null), + ]; + } + + /** + * Version the effective permission context used by Blade and frontend props. + * + * The full permission list can be expensive to rebuild, so this uses the session + * snapshot plus lightweight assignment/version markers that are enough to + * invalidate when direct user or direct group permission assignments change. + */ + private function permissionsVersion(?User $user): ?array + { + if (!$user) { + return null; + } + + $directGroups = $this->directGroupPayload($user); + + return [ + 'is_administrator' => $user->is_administrator, + 'session_permissions' => $this->sessionPermissions(), + 'permissions_table' => $this->tableVersion('permissions'), + 'direct_user_permissions' => $this->assignablePermissionVersion(User::class, [$user->id]), + 'direct_groups' => $directGroups['ids'], + 'direct_group_memberships' => $directGroups['version'], + 'direct_group_permissions' => $this->assignablePermissionVersion( + Group::class, + $directGroups['ids'] + ), + ]; + } + + /** + * Include the current session permission snapshot used by legacy permission checks. + */ + private function sessionPermissions(): array + { + $permissions = session('permissions', []); + + if (!is_array($permissions)) { + return []; + } + + sort($permissions); + + return $permissions; + } + + /** + * Include the default Tasks saved search when the Saved Search package is installed. + */ + private function savedSearchPayload(?User $user): ?array + { + $class = 'ProcessMaker\\Package\\SavedSearch\\Models\\SavedSearch'; + if (!$user || !class_exists($class)) { + return null; + } + + $savedSearch = $class::firstSystemSearchFor($user, $class::KEY_TASKS); + if (!$savedSearch) { + return null; + } + + return [ + 'id' => $savedSearch->id, + 'updated_at' => $this->dateValue($savedSearch->updated_at), + 'columns_hash' => $this->hashValue($savedSearch->columns), + ]; + } + + /** + * Capture the user UI configuration rendered into Tasks page props. + */ + private function userConfigurationPayload(?User $user): array + { + if (!$user) { + return UserConfigurationController::DEFAULT_USER_CONFIGURATION; + } + + $configuration = UserConfiguration::select('updated_at', 'ui_configuration') + ->where('user_id', $user->id) + ->first(); + if (!$configuration) { + return [ + 'updated_at' => null, + 'ui_configuration_hash' => $this->hashValue(UserConfigurationController::DEFAULT_USER_CONFIGURATION), + ]; + } + + return [ + 'updated_at' => $this->dateValue($configuration->updated_at), + 'ui_configuration_hash' => $this->hashValue($configuration->ui_configuration), + ]; + } + + /** + * Return direct group ids and their version marker without loading group models. + */ + private function directGroupPayload(User $user): array + { + $memberships = DB::table('group_members') + ->where('member_type', User::class) + ->where('member_id', $user->id) + ->orderBy('group_id') + ->get(['group_id', 'updated_at']); + + return [ + 'ids' => $memberships->pluck('group_id')->all(), + 'version' => [ + 'count' => $memberships->count(), + 'updated_at' => $this->dateValue($memberships->max('updated_at')), + ], + ]; + } + + /** + * Hash direct permission assignment ids for an assignable type. + */ + private function assignablePermissionVersion(string $assignableType, array $assignableIds): array + { + if (empty($assignableIds)) { + return [ + 'count' => 0, + 'permission_ids_hash' => $this->hashValue([]), + ]; + } + + $permissionIds = DB::table('assignables') + ->where('assignable_type', $assignableType) + ->whereIn('assignable_id', $assignableIds) + ->orderBy('permission_id') + ->pluck('permission_id') + ->all(); + + return [ + 'count' => count($permissionIds), + 'permission_ids_hash' => $this->hashValue($permissionIds), + ]; + } + + /** + * Version a table with a compact count and updated_at marker. + */ + private function tableVersion(string $table): array + { + $version = DB::table($table) + ->selectRaw('COUNT(*) as count, MAX(updated_at) as updated_at') + ->first(); + + return [ + 'count' => (int) ($version->count ?? 0), + 'updated_at' => $this->dateValue($version->updated_at ?? null), + ]; + } + + /** + * Hash structured values before placing them in the ETag payload. + */ + private function hashValue($value): string + { + return hash($this->hashAlgorithm(), json_encode($value)); + } + + /** + * Capture selected config values and frontend asset versions used by the page shell. + */ + private function featuresPayload(): array + { + $features = []; + foreach (self::FEATURE_CONFIG_KEYS as $key) { + Arr::set($features, $key, config($key)); + } + + $features['mix_manifest'] = $this->fileVersion(public_path('mix-manifest.json')); + + return $features; + } + + /** + * Version installed package state so package-provided UI changes invalidate the page. + */ + private function packagesPayload(): array + { + $packages = app(PackageManager::class)->listPackages(); + sort($packages); + + $manifest = app(PackageManifest::class); + + return [ + 'app_version' => $this->appVersion(), + 'registered' => $packages, + 'manifest' => method_exists($manifest, 'list') ? $manifest->list() : $manifest->providers(), + 'composer_lock' => $this->fileVersion(base_path('composer.lock')), + ]; + } + + /** + * Read the ProcessMaker application version from composer metadata. + */ + private function appVersion(): ?string + { + $composer = json_decode(File::get(base_path('composer.json')), true); + + return $composer['version'] ?? null; + } + + /** + * Return a cheap version marker for files that affect rendered assets or packages. + */ + private function fileVersion(string $path): ?array + { + if (!File::exists($path)) { + return null; + } + + return [ + 'mtime' => File::lastModified($path), + 'hash' => hash_file($this->hashAlgorithm(), $path), + ]; + } + + /** + * Prefer xxh128 when available and fall back for older runtimes. + */ + private function hashAlgorithm(): string + { + return in_array('xxh128', hash_algos(), true) ? 'xxh128' : 'sha256'; + } + + /** + * Normalize nullable date values for deterministic JSON hashing. + */ + private function dateValue($value): ?string + { + return $value ? (string) $value : null; + } + + /** + * Recursively sort arrays so query-string order does not change the ETag. + */ + private function sorted(array $value): array + { + ksort($value); + + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->sorted($item); + } + } + + return $value; + } +} diff --git a/ProcessMaker/Http/Resources/ProcessRequests.php b/ProcessMaker/Http/Resources/ProcessRequests.php index f0f87773a8..9c0b387bb7 100644 --- a/ProcessMaker/Http/Resources/ProcessRequests.php +++ b/ProcessMaker/Http/Resources/ProcessRequests.php @@ -22,11 +22,13 @@ public function toArray($request) $array['participants'] = $this->participants; } if (in_array('activeTasks', $include)) { - $array['active_tasks'] = $this->tokens() - ->select(['id', 'element_name', 'status', 'user_id']) - ->where('status', 'ACTIVE') - ->where('element_type', 'task') - ->get(); + $array['active_tasks'] = $this->relationLoaded('activeTasks') + ? $this->activeTasks + : $this->tokens() + ->select(['id', 'element_name', 'status', 'user_id']) + ->where('status', 'ACTIVE') + ->where('element_type', 'task') + ->get(); } if (in_array('user', $include)) { $array['user'] = new Users($this->user); diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f9fcdab5ce..a2e6d21238 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -123,7 +123,7 @@ public function handle() return $response; } - public function transferInternalContext(BpmnAction $action): void + public function transferInternalContext(self $action): void { $action->engine = $this->engine; $action->instance = $this->instance; diff --git a/ProcessMaker/Jobs/GenerateUserRecommendations.php b/ProcessMaker/Jobs/GenerateUserRecommendations.php index ba9d1629d8..024f3d92b0 100644 --- a/ProcessMaker/Jobs/GenerateUserRecommendations.php +++ b/ProcessMaker/Jobs/GenerateUserRecommendations.php @@ -3,6 +3,7 @@ namespace ProcessMaker\Jobs; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -12,10 +13,15 @@ use ProcessMaker\Models\User; use ProcessMaker\RecommendationEngine; -class GenerateUserRecommendations implements ShouldQueue +class GenerateUserRecommendations implements ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Seconds the unique lock is held so duplicate dispatches for the same user are dropped. + */ + public int $uniqueFor = 60; + /** * Create a new job instance. */ @@ -23,6 +29,11 @@ public function __construct(public int $user_id) { } + public function uniqueId(): string + { + return (string) $this->user_id; + } + public function middleware(): array { return [(new WithoutOverlapping($this->user_id))->dontRelease()]; diff --git a/ProcessMaker/Listeners/BpmnSubscriber.php b/ProcessMaker/Listeners/BpmnSubscriber.php index c7f0f7f810..a4022471ba 100644 --- a/ProcessMaker/Listeners/BpmnSubscriber.php +++ b/ProcessMaker/Listeners/BpmnSubscriber.php @@ -146,22 +146,25 @@ public function onActivityCompleted(ActivityCompletedEvent $event) Log::info('Activity completed: ' . json_encode($token->getProperties())); // Prometheus Metric: Activity Execution Time - $startTime = $token->created_at_ms; - $completedTime = $token->completed_at_ms; - $executionTime = $completedTime->diffInMilliseconds($startTime); - Metrics::histogramObserve( - 'activity_execution_time_seconds', - 'Activity Execution Time', - [ - 'activity_id' => $token->element_id, - 'activity_name' => $token->element_name, - 'element_type' => $token->element_type, - 'process_id' => $token->process_id, - 'request_id' => $token->process_request_id, - ], - [1, 10, 3600, 86400], - $executionTime, - ); + $startTime = $token->created_at_ms ?? $token->created_at; + $completedTime = $token->completed_at_ms ?? $token->completed_at; + + if ($startTime && $completedTime) { + $executionTime = $completedTime->diffInMilliseconds($startTime); + Metrics::histogramObserve( + 'activity_execution_time_seconds', + 'Activity Execution Time', + [ + 'activity_id' => $token->element_id, + 'activity_name' => $token->element_name, + 'element_type' => $token->element_type, + 'process_id' => $token->process_id, + 'request_id' => $token->process_request_id, + ], + [1, 10, 3600, 86400], + $executionTime, + ); + } if ($token->element_type == 'task') { $notifiables = $token->getNotifiables('completed'); diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index f7390f1aaa..a953928f69 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -342,13 +342,13 @@ public function embed() public function getNotificationsAttribute() { $array = []; + $settings = $this->notification_settings->whereNull('element_id'); foreach ($this->requestNotifiableTypes as $notifiable) { foreach ($this->requestNotificationTypes as $notification) { - $setting = $this->notification_settings() - ->whereNull('element_id') + $setting = $settings ->where('notifiable_type', $notifiable) - ->where('notification_type', $notification)->get(); + ->where('notification_type', $notification); if ($setting->count()) { $value = true; @@ -372,9 +372,7 @@ public function getTaskNotificationsAttribute() { $array = []; - $elements = $this->notification_settings() - ->whereNotNull('element_id') - ->get(); + $elements = $this->notification_settings->whereNotNull('element_id'); foreach ($elements->groupBy('element_id') as $group) { $elementId = $group->first()->element_id; @@ -1613,6 +1611,10 @@ public function setProcessCategoryIdAttribute($value) */ public function getProcessCategoryIdAttribute($value) { + if ($this->relationLoaded('categories')) { + return implode(',', $this->categories->pluck('pivot.category_id')->toArray()) ?: $value; + } + return implode(',', $this->categories()->pluck('category_id')->toArray()) ?: $value; } diff --git a/ProcessMaker/Models/ProcessMakerModel.php b/ProcessMaker/Models/ProcessMakerModel.php index 1fb77d2728..999bd82f6c 100644 --- a/ProcessMaker/Models/ProcessMakerModel.php +++ b/ProcessMaker/Models/ProcessMakerModel.php @@ -36,7 +36,7 @@ public function scopeExclude($query, array $columns) $columnsToShow = array_diff($this->getTableColumns(), $columns); $columnsToShow = array_map(function ($column) { - return $this->table . '.' . $column; + return $this->getTable() . '.' . $column; }, $columnsToShow); return $query->select($columnsToShow); diff --git a/ProcessMaker/Models/ProcessRequest.php b/ProcessMaker/Models/ProcessRequest.php index 711131d91b..3424d0ab6f 100644 --- a/ProcessMaker/Models/ProcessRequest.php +++ b/ProcessMaker/Models/ProcessRequest.php @@ -184,6 +184,28 @@ public function shouldBeSearchable() } } + /** + * Determine if the model existed in the search index prior to an update. + * Prevents unnecessary RemoveFromSearch jobs when indexed search is disabled. + * + * @return bool + */ + public function wasSearchableBeforeUpdate() + { + return $this->shouldBeSearchable(); + } + + /** + * Determine if the model existed in the search index prior to deletion. + * Prevents unnecessary RemoveFromSearch jobs when indexed search is disabled. + * + * @return bool + */ + public function wasSearchableBeforeDelete() + { + return $this->shouldBeSearchable(); + } + /** * Get the indexable data array for the model. * @@ -403,6 +425,17 @@ public function tokens() return $this->hasMany(ProcessRequestToken::class); } + /** + * Active task tokens for this request. + */ + public function activeTasks() + { + return $this->hasMany(ProcessRequestToken::class) + ->select(['id', 'element_name', 'status', 'user_id', 'process_request_id']) + ->where('status', 'ACTIVE') + ->where('element_type', 'task'); + } + /** * Get the creator/author of this request. */ @@ -1088,6 +1121,10 @@ public function isSystem() public function getProcessVersionAlternativeAttribute(): string | null { + if (array_key_exists('process_version_alternative', $this->attributes)) { + return $this->attributes['process_version_alternative'] ?? 'A'; + } + return $this->processVersion?->alternative ?? 'A'; } diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index baca50850f..80d9852488 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -201,6 +201,28 @@ public function shouldBeSearchable() } } + /** + * Determine if the model existed in the search index prior to an update. + * Prevents unnecessary RemoveFromSearch jobs when indexed search is disabled. + * + * @return bool + */ + public function wasSearchableBeforeUpdate() + { + return $this->shouldBeSearchable(); + } + + /** + * Determine if the model existed in the search index prior to deletion. + * Prevents unnecessary RemoveFromSearch jobs when indexed search is disabled. + * + * @return bool + */ + public function wasSearchableBeforeDelete() + { + return $this->shouldBeSearchable(); + } + /** * Boot application as a process instance. * diff --git a/ProcessMaker/Repositories/ExecutionInstanceRepository.php b/ProcessMaker/Repositories/ExecutionInstanceRepository.php index 96b83e4236..ccad705191 100644 --- a/ProcessMaker/Repositories/ExecutionInstanceRepository.php +++ b/ProcessMaker/Repositories/ExecutionInstanceRepository.php @@ -14,6 +14,7 @@ use ProcessMaker\Nayra\Contracts\Repositories\ExecutionInstanceRepositoryInterface; use ProcessMaker\Nayra\Contracts\Repositories\StorageInterface; use ProcessMaker\Nayra\RepositoryTrait; +use ProcessMaker\Repositories\TokenPersistenceRawRepository; use ProcessMaker\SanitizeHelper; /** @@ -236,6 +237,16 @@ public function persistInstanceUpdated(ExecutionInstanceInterface $instance) return; } + if ( + config('app.token_persistence_raw_enabled', false) + && $instance instanceof ProcessRequest + ) { + app(TokenPersistenceRawRepository::class)->persistInstanceUpdated($instance); + CaseUpdateStatus::dispatchSync($instance); + + return; + } + // Save updated instance if (!$instance->status) { $instance->status = 'ACTIVE'; diff --git a/ProcessMaker/Repositories/ProcessRequestListingRawRepository.php b/ProcessMaker/Repositories/ProcessRequestListingRawRepository.php new file mode 100644 index 0000000000..1cd9b6c655 --- /dev/null +++ b/ProcessMaker/Repositories/ProcessRequestListingRawRepository.php @@ -0,0 +1,129 @@ +getCollection() + : $requests; + + if ($collection->isEmpty()) { + return; + } + + $requestIds = $collection->pluck('id')->all(); + + if (in_array('activeTasks', $includeList, true)) { + $this->hydrateActiveTasks($collection, $requestIds); + } + + if (in_array('process', $includeList, true)) { + $processIds = $collection->pluck('process_id')->unique()->filter()->values()->all(); + $this->hydrateProcesses($collection, $processIds); + } + } + + private function hydrateActiveTasks(Collection $requests, array $requestIds): void + { + $placeholders = implode(',', array_fill(0, count($requestIds), '?')); + $rows = DB::select( + "SELECT id, process_request_id, element_name, status, user_id + FROM process_request_tokens + WHERE process_request_id IN ({$placeholders}) + AND status = 'ACTIVE' + AND element_type = 'task'", + $requestIds + ); + + $grouped = collect($rows)->groupBy('process_request_id'); + + foreach ($requests as $request) { + $tasks = $grouped->get($request->id, collect())->map(function ($row) { + $token = new ProcessRequestToken(); + $token->setRawAttributes([ + 'id' => $row->id, + 'element_name' => $row->element_name, + 'status' => $row->status, + 'user_id' => $row->user_id, + ], true); + $token->exists = true; + + return $token; + }); + + $request->setRelation('activeTasks', $tasks); + } + } + + private function hydrateProcesses(Collection $requests, array $processIds): void + { + if ($processIds === []) { + return; + } + + $projectsByProcessId = $this->loadProjectsJsonByProcessIds($processIds); + + $processesById = Process::query() + ->whereIn('id', $processIds) + ->with('categories') + ->get() + ->keyBy('id'); + + foreach ($processesById as $process) { + if (isset($projectsByProcessId[$process->id])) { + $attributes = $process->getAttributes(); + $attributes['projects'] = $projectsByProcessId[$process->id]; + $process->setRawAttributes($attributes, true); + } + } + + foreach ($requests as $request) { + if ($processesById->has($request->process_id)) { + $request->setRelation('process', $processesById->get($request->process_id)); + } + } + } + + /** + * @return array + */ + private function loadProjectsJsonByProcessIds(array $processIds): array + { + $projectAssetClass = 'ProcessMaker\Package\Projects\Models\ProjectAsset'; + + if (!class_exists($projectAssetClass)) { + return array_fill_keys($processIds, json_encode([])); + } + + $projectAssets = $projectAssetClass::query() + ->whereIn('asset_id', $processIds) + ->where('asset_type', Process::class) + ->with('project') + ->get() + ->groupBy('asset_id'); + + $result = []; + + foreach ($processIds as $processId) { + $result[$processId] = json_encode( + ($projectAssets->get($processId) ?? collect())->map(function ($projectAsset) { + return $projectAsset->project; + }) + ); + } + + return $result; + } +} diff --git a/ProcessMaker/Repositories/TaskCompletionRawRepository.php b/ProcessMaker/Repositories/TaskCompletionRawRepository.php new file mode 100644 index 0000000000..8913415302 --- /dev/null +++ b/ProcessMaker/Repositories/TaskCompletionRawRepository.php @@ -0,0 +1,160 @@ +is_self_service = (bool) $row->is_self_service; + $row->self_service_groups = $this->decodeJson($row->self_service_groups); + + return $row; + } + + public function findProcessForComplete(int $processId): ?stdClass + { + $row = DB::selectOne( + 'SELECT id, bpmn, start_events, properties, status, name, process_category_id + FROM processes + WHERE id = ? AND deleted_at IS NULL + LIMIT 1', + [$processId] + ); + + if ($row === null) { + return null; + } + + $row->properties = $this->decodeJson($row->properties) ?? []; + $row->manager_id = $this->decodeManagerIds($row->properties['manager_id'] ?? null); + $row->start_events = $this->decodeJson($row->start_events); + + return $row; + } + + public function findProcessRequestForComplete(int $processRequestId): ?stdClass + { + $row = DB::selectOne( + 'SELECT id, process_id, process_version_id, status, do_not_sanitize, user_id, + parent_request_id, process_collaboration_id + FROM process_requests + WHERE id = ? + LIMIT 1', + [$processRequestId] + ); + + if ($row === null) { + return null; + } + + $row->do_not_sanitize = $this->decodeJson($row->do_not_sanitize) ?? []; + + return $row; + } + + public function findProcessVersionForComplete(?int $processVersionId): ?stdClass + { + if ($processVersionId === null) { + return null; + } + + $row = DB::selectOne( + 'SELECT id, process_id, bpmn, start_events, status, name, alternative + FROM process_versions + WHERE id = ? + LIMIT 1', + [$processVersionId] + ); + + if ($row === null) { + return null; + } + + $row->start_events = $this->decodeJson($row->start_events); + + return $row; + } + + public function taskHasDraft(int $taskId): bool + { + return $this->executionRawRepository->taskHasDraftRaw($taskId); + } + + public function findTaskForResponse(int $taskId): ?stdClass + { + return DB::selectOne( + 'SELECT id, element_name, element_id, element_type, status, due_at, process_request_id, + user_id, process_id, is_self_service, self_service_groups, token_properties, + created_at, updated_at, completed_at + FROM process_request_tokens + WHERE id = ? + LIMIT 1', + [$taskId] + ); + } + + /** + * @return list + */ + private function decodeManagerIds(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_array($value)) { + return array_map('intval', $value); + } + + if (is_numeric($value)) { + return [(int) $value]; + } + + $decoded = $this->decodeJson($value); + + if (is_array($decoded)) { + return array_map('intval', $decoded); + } + + return []; + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode((string) $value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } +} diff --git a/ProcessMaker/Repositories/TokenPersistenceRawRepository.php b/ProcessMaker/Repositories/TokenPersistenceRawRepository.php new file mode 100644 index 0000000000..726b7ff2f8 --- /dev/null +++ b/ProcessMaker/Repositories/TokenPersistenceRawRepository.php @@ -0,0 +1,162 @@ +updateToken($token, [ + 'status', + 'element_id', + 'element_type', + 'element_name', + 'process_id', + 'process_request_id', + 'user_id', + 'is_self_service', + 'self_service_groups', + 'due_at', + 'initiated_at', + 'riskchanges_at', + 'token_properties', + 'stage_id', + 'stage_name', + ]); + } + + /** + * Persist token fields after persistActivityCompleted. + */ + public function saveCompletedToken(ProcessRequestToken $token): void + { + $this->updateToken($token, [ + 'status', + 'element_id', + 'process_request_id', + 'completed_at', + 'token_properties', + ]); + } + + /** + * Persist token fields after persistActivityClosed. + */ + public function saveClosedToken(ProcessRequestToken $token): void + { + $this->updateToken($token, [ + 'status', + 'element_id', + 'element_type', + 'element_name', + 'process_id', + 'process_request_id', + 'data', + 'token_properties', + ]); + } + + /** + * Analog to ExecutionInstanceRepository::persistInstanceUpdated without Eloquent save. + */ + public function persistInstanceUpdated(ProcessRequest $instance): void + { + $store = $instance->getDataStore(); + $row = DB::selectOne( + 'SELECT data, execution_revision FROM process_requests WHERE id = ? LIMIT 1', + [$instance->getKey()] + ); + + if (!$instance->status) { + $instance->status = 'ACTIVE'; + } + + $storedData = $row && $row->data ? json_decode((string) $row->data, true) : []; + $mergedData = $store->updateArray(is_array($storedData) ? $storedData : []); + $newRevision = (int) ($row->execution_revision ?? 0) + 1; + + $instance->data = $mergedData; + $instance->execution_revision = $newRevision; + + $payload = [ + 'data' => json_encode($mergedData), + 'execution_revision' => $newRevision, + 'updated_at' => Carbon::now(), + ]; + + foreach (['status', 'last_stage_id', 'last_stage_name', 'progress', 'completed_at'] as $field) { + if (array_key_exists($field, $instance->getDirty())) { + $payload[$field] = $instance->getAttributes()[$field]; + } + } + + $this->runUpdate('process_requests', (int) $instance->getKey(), $payload); + $instance->syncChanges(); + } + + /** + * @param list $fields + */ + private function updateToken(ProcessRequestToken $token, array $fields): void + { + $payload = []; + foreach ($fields as $field) { + if (!array_key_exists($field, $token->getAttributes())) { + continue; + } + $payload[$field] = $this->serializeColumnValue($field, $token->getAttributes()[$field]); + } + + $payload['updated_at'] = Carbon::now(); + + $tokenId = (int) $token->getKey(); + if ($tokenId <= 0) { + $token->saveOrFail(); + + return; + } + + $this->runUpdate('process_request_tokens', $tokenId, $payload); + $token->syncChanges(); + } + + /** + * @param array $payload + */ + private function runUpdate(string $table, int $id, array $payload): void + { + if ($payload === []) { + return; + } + + $columns = array_keys($payload); + $assignments = implode(', ', array_map(static fn (string $column): string => "`{$column}` = ?", $columns)); + $values = array_values($payload); + $values[] = $id; + + DB::update("UPDATE `{$table}` SET {$assignments} WHERE `id` = ?", $values); + } + + private function serializeColumnValue(string $field, mixed $value): mixed + { + if (in_array($field, ['self_service_groups', 'token_properties', 'data'], true)) { + return $value === null ? null : json_encode($value); + } + + if ($value instanceof Carbon) { + return $value->format('Y-m-d H:i:s'); + } + + return $value; + } +} diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 33da49a451..aa32d789e5 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -174,7 +174,7 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $token->riskchanges_at = $due ? Carbon::now()->addHours($due * 0.7) : null; $token->updateTokenProperties(); $token->getInstance()->updateCatchEvents(); - $token->saveOrFail(); + $this->saveToken($token); $token->setId($token->getKey()); $request = $token->getInstance(); $request->last_stage_id = $token->stage_id; @@ -357,7 +357,7 @@ public function persistActivityCompleted(ActivityInterface $activity, TokenInter $token->process_request_id = $token->getInstance()->getKey(); $token->completed_at = Carbon::now(); $token->updateTokenProperties(); - $token->save(); + $this->saveToken($token, 'completed'); $token->setId($token->getKey()); $this->updateCaseStartedTask($token); @@ -391,7 +391,7 @@ public function persistActivityClosed(ActivityInterface $activity, TokenInterfac $token->process_request_id = $token->getInstance()->getKey(); $token->data = $token->getInstance()->getDataStore()->getData(); $token->updateTokenProperties(); - $token->save(); + $this->saveToken($token, 'closed'); $token->setId($token->getKey()); } @@ -719,4 +719,26 @@ private function updateCaseStartedTask(TokenInterface $token): void $caseTaskRepo->updateCaseStartedTaskStatus(); $caseTaskRepo->updateCaseParticipatedTaskStatus(); } + + private function tokenPersistenceUsesRawSql(): bool + { + return (bool) config('app.token_persistence_raw_enabled', false); + } + + private function saveToken(TokenInterface $token, string $context = 'activated'): void + { + if (!$this->tokenPersistenceUsesRawSql() || !$token instanceof ProcessRequestToken) { + $context === 'activated' ? $token->saveOrFail() : $token->save(); + + return; + } + + $repository = app(TokenPersistenceRawRepository::class); + + match ($context) { + 'completed' => $repository->saveCompletedToken($token), + 'closed' => $repository->saveClosedToken($token), + default => $repository->saveActivatedToken($token), + }; + } } diff --git a/ProcessMaker/Services/TaskCompletionRawService.php b/ProcessMaker/Services/TaskCompletionRawService.php new file mode 100644 index 0000000000..ca56244a9c --- /dev/null +++ b/ProcessMaker/Services/TaskCompletionRawService.php @@ -0,0 +1,126 @@ + $payload + * @return array + */ + public function completeTask(int $taskId, array $payload, User $user): array + { + if (!$this->isEnabled()) { + throw new NotFoundHttpException( + __('Task update API v1.1 is disabled. Use PUT /api/1.0/tasks/{id} instead.') + ); + } + + $taskRow = $this->repository->findTaskForUpdate($taskId); + + if ($taskRow === null) { + throw new NotFoundHttpException(__('Task not found')); + } + + if ($taskRow->status === 'CLOSED') { + abort(422, __('Task already closed')); + } + + $processRow = $this->repository->findProcessForComplete((int) $taskRow->process_id); + + if ($processRow === null) { + throw new NotFoundHttpException(__('Process not found')); + } + + Gate::forUser($user)->authorize( + 'update', + $this->engineBridge->hydrateTokenForPolicy($taskRow, $processRow) + ); + + $requestRow = $this->repository->findProcessRequestForComplete((int) $taskRow->process_request_id); + + if ($requestRow === null) { + throw new NotFoundHttpException(__('Process request not found')); + } + + $versionRow = $this->repository->findProcessVersionForComplete( + $requestRow->process_version_id ? (int) $requestRow->process_version_id : null + ); + + $data = SanitizeHelper::sanitizeData( + $payload['data'] ?? [], + null, + $requestRow->do_not_sanitize ?? [] + ); + + $this->engineBridge->complete( + $taskRow, + $processRow, + $requestRow, + $versionRow, + $data, + $this->repository->taskHasDraft($taskId), + ); + + $responseRow = $this->repository->findTaskForResponse($taskId); + + if ($responseRow === null) { + throw new NotFoundHttpException(__('Task not found')); + } + + return $this->formatTaskResponse($responseRow); + } + + /** + * @return array + */ + private function formatTaskResponse(object $row): array + { + return [ + 'id' => (int) $row->id, + 'element_name' => $row->element_name, + 'element_id' => $row->element_id, + 'element_type' => $row->element_type, + 'status' => $row->status, + 'due_at' => $row->due_at, + 'process_request_id' => (int) $row->process_request_id, + 'is_self_service' => (bool) $row->is_self_service, + 'token_properties' => $this->decodeJson($row->token_properties ?? null), + ]; + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode((string) $value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } +} diff --git a/ProcessMaker/Support/TaskCompletionEngineBridge.php b/ProcessMaker/Support/TaskCompletionEngineBridge.php new file mode 100644 index 0000000000..b3f697ceb1 --- /dev/null +++ b/ProcessMaker/Support/TaskCompletionEngineBridge.php @@ -0,0 +1,150 @@ +hydrateProcess($processRow); + $task = $this->hydrateModel( + ProcessRequestToken::class, + $this->encodeArrayCasts((array) $taskRow, ['self_service_groups']) + ); + $task->setRelation('process', $process); + + return $task; + } + + public function complete( + stdClass $taskRow, + stdClass $processRow, + stdClass $requestRow, + ?stdClass $versionRow, + array $data, + bool $hasDraft, + ): void { + if ($hasDraft && TaskDraft::draftsEnabled()) { + $task = $this->hydrateToken($taskRow, $requestRow, $processRow); + TaskDraft::moveDraftFiles($task); + } + + $process = $this->hydrateProcess($processRow); + $processVersion = $versionRow ? $this->hydrateProcessVersion($versionRow, $processRow) : null; + $instance = $this->hydrateProcessRequest($requestRow, $process, $processVersion); + $task = $this->hydrateToken($taskRow, $requestRow, $processRow, $instance, $process); + + WorkflowManager::completeTask($process, $instance, $task, $data); + } + + private function hydrateProcess(stdClass $row): Process + { + $attributes = (array) $row; + $properties = is_array($attributes['properties'] ?? null) + ? $attributes['properties'] + : []; + + if (!empty($row->manager_id)) { + $properties['manager_id'] = $row->manager_id; + } + + $attributes['properties'] = $properties; + unset($attributes['manager_id']); + + return $this->hydrateModel(Process::class, $this->encodeArrayCasts($attributes, ['properties', 'start_events'])); + } + + private function hydrateProcessVersion(stdClass $row, stdClass $processRow): ProcessVersion + { + $process = $this->hydrateProcess($processRow); + $version = $this->hydrateModel( + ProcessVersion::class, + $this->encodeArrayCasts((array) $row, ['start_events']) + ); + $version->setRelation('process', $process); + + return $version; + } + + private function hydrateProcessRequest( + stdClass $row, + Process $process, + ?ProcessVersion $processVersion, + ): ProcessRequest { + $instance = $this->hydrateModel( + ProcessRequest::class, + $this->encodeArrayCasts((array) $row, ['do_not_sanitize']) + ); + $instance->setRelation('process', $process); + + if ($processVersion !== null) { + $instance->setRelation('processVersion', $processVersion); + } + + return $instance; + } + + private function hydrateToken( + stdClass $taskRow, + stdClass $requestRow, + stdClass $processRow, + ?ProcessRequest $instance = null, + ?Process $process = null, + ): ProcessRequestToken { + $task = $this->hydrateModel( + ProcessRequestToken::class, + $this->encodeArrayCasts((array) $taskRow, ['self_service_groups']) + ); + + if ($instance === null || $process === null) { + $process ??= $this->hydrateProcess($processRow); + $instance ??= $this->hydrateProcessRequest($requestRow, $process, null); + } + + $task->setRelation('processRequest', $instance); + $task->setRelation('process', $process); + + return $task; + } + + private function hydrateModel(string $class, array $attributes): mixed + { + return $this->executionRawRepository->hydrateModelFromRowRaw($class, (object) $attributes); + } + + /** + * Eloquent array casts expect JSON strings in raw attributes. + * + * @param list $fields + */ + private function encodeArrayCasts(array $attributes, array $fields): array + { + foreach ($fields as $field) { + if (isset($attributes[$field]) && is_array($attributes[$field])) { + $attributes[$field] = json_encode($attributes[$field]); + } + } + + return $attributes; + } +} diff --git a/ProcessMaker/Traits/HideSystemResources.php b/ProcessMaker/Traits/HideSystemResources.php index 6428fb930c..bd42d589e7 100644 --- a/ProcessMaker/Traits/HideSystemResources.php +++ b/ProcessMaker/Traits/HideSystemResources.php @@ -3,6 +3,7 @@ namespace ProcessMaker\Traits; use Facades\ProcessMaker\Helpers\CachedSchema; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use ProcessMaker\Models\Process; @@ -57,7 +58,7 @@ public function scopeSystem($query) } } - public function scopeNonSystem($query) + public function scopeNonSystem($query, $optimized = false) { if (substr(static::class, -8) === 'Category') { return $query->where('is_system', false); @@ -105,9 +106,25 @@ public function scopeNonSystem($query) } elseif (static::class == User::class) { return $query->where('is_system', false); } elseif (static::class === ProcessRequestToken::class) { - return $query->whereHas('process.categories', function ($query) { - $query->where('is_system', false); - }); + // Direct EXISTS avoids Process global scopes (e.g. published/process_versions). + if (!$optimized) { + return $query->whereHas('process.categories', function ($query) { + $query->where('is_system', false); + }); + } else { + return $query->whereExists(function ($sub) { + $sub->select(DB::raw(1)) + ->from('processes') + ->join('category_assignments', function ($join) { + $join->on('category_assignments.assignable_id', '=', 'processes.id') + ->where('category_assignments.assignable_type', '=', Process::class); + }) + ->join('process_categories', 'process_categories.id', '=', 'category_assignments.category_id') + ->whereColumn('processes.id', 'process_request_tokens.process_id') + ->whereNull('processes.deleted_at') + ->where('process_categories.is_system', false); + }); + } } elseif (static::class === ProcessTemplates::class) { return $query->where('process_templates.is_system', false) ->when(CachedSchema::hasColumn('process_templates', 'asset_type'), function ($query) { diff --git a/ProcessMaker/Traits/ProjectAssetTrait.php b/ProcessMaker/Traits/ProjectAssetTrait.php index 7046a40887..946c66520e 100644 --- a/ProcessMaker/Traits/ProjectAssetTrait.php +++ b/ProcessMaker/Traits/ProjectAssetTrait.php @@ -77,6 +77,10 @@ private function convertStringToArray($input) public function getProjectsAttribute() { + if (array_key_exists('projects', $this->attributes) && is_string($this->attributes['projects'])) { + return $this->attributes['projects']; + } + if (class_exists(self::PROJECT_ASSET_MODEL_CLASS)) { $projectAssets = self::PROJECT_ASSET_MODEL_CLASS::where('asset_id', $this->id) ->where('asset_type', get_class($this)) diff --git a/ProcessMaker/Traits/TaskControllerIndexMethods.php b/ProcessMaker/Traits/TaskControllerIndexMethods.php index 2ad7b59031..3f2c56436e 100644 --- a/ProcessMaker/Traits/TaskControllerIndexMethods.php +++ b/ProcessMaker/Traits/TaskControllerIndexMethods.php @@ -21,6 +21,21 @@ trait TaskControllerIndexMethods { private const SELF_SERVICE_STATUS = 'self service'; + private const INDEX_JSON_COLUMNS = ['data', 'self_service_groups', 'token_properties']; + + private const INDEX_COLUMN_FIELD_MAP = [ + 'case_number' => ['process_request_id'], + 'case_title' => ['process_request_id'], + 'is_priority' => ['is_priority'], + 'element_name' => ['element_name', 'element_type'], + 'status' => ['status', 'is_self_service', 'due_at'], + 'due_at' => ['due_at'], + 'completed_at' => ['completed_at'], + 'process' => ['process_id'], + 'assignee' => ['user_id', 'is_self_service'], + 'request' => ['process_request_id', 'process_id'], + ]; + private function indexBaseQuery($request) { // Parse the includes parameter @@ -62,6 +77,122 @@ private function indexBaseQuery($request) return $query; } + private function indexOptimizedBaseQuery($request) + { + $includes = $request->has('include') + ? array_map('trim', explode(',', $request->input('include'))) + : []; + $includeData = in_array('data', $includes, true); + + $query = ProcessRequestToken::query(); + $with = []; + + if (in_array('processRequest', $includes, true)) { + $processRequestColumns = [ + 'id', + 'uuid', + 'case_number', + 'case_title', + 'name', + 'status', + 'user_id', + 'process_id', + 'parent_id', + ]; + $with['processRequest'] = function ($q) use ($includeData, $processRequestColumns) { + $q->select($processRequestColumns); + if (!$includeData) { + $q->exclude(['data']); + } + }; + } + + if (in_array('process', $includes, true)) { + $with['process'] = fn ($q) => $q->select(['id', 'name', 'uuid']); + } + + if (in_array('user', $includes, true)) { + $with['user'] = fn ($q) => $q->select(['id', 'firstname', 'lastname', 'avatar', 'status']); + } + + if (in_array('draft', $includes, true)) { + $with['draft'] = fn ($q) => $q->select(['id', 'uuid', 'process_request_token_id']); + } + + $handledIncludes = ['data', 'processRequest', 'process', 'user', 'draft', 'processRequest.process']; + $additionalIncludes = array_values(array_diff($includes, $handledIncludes)); + + if (!empty($with)) { + $query->with($with); + } + + if (!empty($additionalIncludes)) { + $query->with($additionalIncludes); + } + + return $query; + } + + private function resolveIndexFields($request): ?array + { + $fields = $request->input('fields', ''); + if ($fields) { + $selectedFields = array_filter(array_map('trim', explode(',', $fields))); + } else { + $columns = $request->input('columns', ''); + if (!$columns) { + return null; + } + $selectedFields = $this->mapColumnsToFields(array_map('trim', explode(',', $columns))); + } + + if (!in_array('id', $selectedFields, true)) { + $selectedFields[] = 'id'; + } + + return array_values(array_unique($selectedFields)); + } + + private function mapColumnsToFields(array $columns): array + { + $fields = []; + + foreach ($columns as $column) { + if (str_starts_with($column, 'data.')) { + continue; + } + + if (isset(self::INDEX_COLUMN_FIELD_MAP[$column])) { + $fields = array_merge($fields, self::INDEX_COLUMN_FIELD_MAP[$column]); + continue; + } + + if (in_array($column, ['draft', 'actions'], true)) { + continue; + } + + $fields[] = $column; + } + + return array_values(array_unique(array_merge( + $fields, + ['id', 'process_id', 'process_request_id'] + ))); + } + + private function applyIndexFieldSelection($query, $request): void + { + $selectedFields = $this->resolveIndexFields($request); + + if ($selectedFields !== null) { + $query->select($selectedFields); + + return; + } + + $query->exclude(self::INDEX_JSON_COLUMNS); + } + private function applyFilters($query, $request) { $filter = $request->input('filter', ''); @@ -160,6 +291,7 @@ private function addTaskData($response) private function excludeNonVisibleTasks($query, $request) { $nonSystem = filter_var($request->input('non_system'), FILTER_VALIDATE_BOOLEAN); + $optimized = filter_var($request->input('optimized'), FILTER_VALIDATE_BOOLEAN); $allTasks = filter_var($request->input('all_tasks'), FILTER_VALIDATE_BOOLEAN); $hitlEnabled = app(SmartExtractConfiguration::class)->hitlEnabled(); $includeScreen = filter_var($request->input('includeScreen'), FILTER_VALIDATE_BOOLEAN); @@ -177,15 +309,15 @@ private function excludeNonVisibleTasks($query, $request) }); }); }) - ->when($nonSystem, function ($query) use ($hitlEnabled) { + ->when($nonSystem, function ($query) use ($hitlEnabled, $optimized) { if (!$hitlEnabled) { - $query->nonSystem(); + $query->nonSystem($optimized); return; } - $query->where(function ($query) { - $query->nonSystem(); + $query->where(function ($query) use ($optimized) { + $query->nonSystem($optimized); $query->orWhere(function ($query) { $query->where('element_type', '=', 'task'); $query->where('element_name', '=', 'Manual Document Review'); diff --git a/bootstrap/app.php b/bootstrap/app.php index d4f47576d4..7f9b9519a8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -114,6 +114,7 @@ 'admin' => ProcessMakerMiddleware\IsAdmin::class, 'manager' => ProcessMakerMiddleware\IsManager::class, 'etag' => ProcessMakerMiddleware\Etag\HandleEtag::class, + 'tasks-page-etag' => ProcessMakerMiddleware\Etag\TasksPageEtag::class, 'file_size_check' => ProcessMakerMiddleware\FileSizeCheck::class, 'auth.basic' => Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'throttle' => Illuminate\Routing\Middleware\ThrottleRequests::class, diff --git a/composer.json b/composer.json index 1d0ddcd12a..f98e57f445 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "processmaker/processmaker", - "version": "2026.16.2", + "version": "2026.17.1-p1", "description": "BPM PHP Software", "keywords": [ "php bpm processmaker" @@ -182,7 +182,7 @@ "package-product-analytics": "1.5.12", "package-projects": "1.12.12", "package-rpa": "1.1.2", - "package-savedsearch": "1.43.23", + "package-savedsearch": "dev-epic/FOUR-32144", "package-slideshow": "1.4.3", "package-smart-extract": "1.0.3", "package-signature": "1.15.6", diff --git a/config/app.php b/config/app.php index b3c5891f98..9aa489a460 100644 --- a/config/app.php +++ b/config/app.php @@ -85,6 +85,9 @@ // System-level scripts timeout 'processmaker_system_scripts_timeout_seconds' => env('PROCESSMAKER_SYSTEM_SCRIPTS_TIMEOUT_SECONDS', 300), + // Enable optimized tasks + 'processmaker_optimized_tasks_enabled' => env('OPTIMIZED_TASKS_ENABLED', true), + // Since the task scheduler has a preset of one minute (crontab), the times // must be rounded or truncated to the nearest HH:MM:00 before compare 'timer_events_seconds' => env('TIMER_EVENTS_SECONDS', 'truncate'), @@ -264,6 +267,12 @@ 'task_drafts_enabled' => env('TASK_DRAFTS_ENABLED', true), + // Raw-query PUT /api/1.1/tasks/{id} for optimized task completion (FOUR-32800). + 'task_update_v1_1_enabled' => env('TASK_UPDATE_V1_1_ENABLED', false), + + // Raw SQL for TokenRepository persistActivity* and getNextUser (FOUR-32800 option 3). + 'token_persistence_raw_enabled' => env('TOKEN_PERSISTENCE_RAW_ENABLED', false), + 'force_https' => env('FORCE_HTTPS', true), 'nayra_docker_network' => env('NAYRA_DOCKER_NETWORK', 'host'), diff --git a/docs/tasks-page-etag-sequence.md b/docs/tasks-page-etag-sequence.md new file mode 100644 index 0000000000..68efe9c5cc --- /dev/null +++ b/docs/tasks-page-etag-sequence.md @@ -0,0 +1,95 @@ +# Tasks Page ETag Sequence + +This diagram captures the current request flow for the Tasks page shell (`/tasks`). The route-specific middleware computes a stable Tasks page ETag before rendering, short-circuits matching conditional requests with `304 Not Modified`, and sets private revalidation headers. The legacy inbox route (`/inbox/{router?}`) remains on the original `no-cache` middleware path and does not use this ETag flow. The global browser-cache middleware preserves ETag-enabled responses instead of applying `no-store`. + +![Tasks page ETag sequence](tasks-page-etag-sequence.svg) + +```mermaid +sequenceDiagram + autonumber + participant Browser as Browser + participant BrowserCache as BrowserCache
ProcessMaker\Http\Middleware\BrowserCache + participant Router as Laravel Router + participant TasksPageEtagMw as TasksPageEtag middleware
ProcessMaker\Http\Middleware\Etag\TasksPageEtag + participant Payload as TasksPageEtag payload
ProcessMaker\Http\Resources\Caching\TasksPageEtag + participant SymfonyResponse as Response
Symfony\Component\HttpFoundation\Response + participant Controller as TaskController@index
ProcessMaker\Http\Controllers\TaskController + + Browser->>BrowserCache: GET /tasks
Cookie + optional If-None-Match + BrowserCache->>Router: pass request through global middleware stack + Router->>TasksPageEtagMw: dispatch route with tasks-page-etag middleware + + alt ETags disabled or method is not GET/HEAD + TasksPageEtagMw->>Controller: bypass ETag logic + Controller-->>TasksPageEtagMw: normal page response + TasksPageEtagMw-->>BrowserCache: response without Tasks page ETag handling + else ETags enabled and method is GET/HEAD + TasksPageEtagMw->>Payload: getEtag(request) + Payload->>Payload: collect route name/path/router/query + Payload->>Payload: collect user and tenant version markers + Payload->>Payload: collect permission table/session/direct assignment markers + Payload->>Payload: collect saved search id/updated_at/columns hash + Payload->>Payload: collect user config hash and task drafts flag + Payload->>Payload: collect feature config, package list, manifest, asset versions + Payload->>Payload: exclude CSRF, session id, randomized favicon URL + Payload-->>TasksPageEtagMw: quoted stable hash + TasksPageEtagMw->>SymfonyResponse: create empty response + set weak ETag + TasksPageEtagMw->>SymfonyResponse: isNotModified(request) + + alt If-None-Match matches weak ETag + SymfonyResponse-->>TasksPageEtagMw: true + TasksPageEtagMw->>SymfonyResponse: build 304 response with same weak ETag + TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate + TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires + TasksPageEtagMw-->>BrowserCache: 304 Not Modified + else Missing/stale If-None-Match + SymfonyResponse-->>TasksPageEtagMw: false + TasksPageEtagMw->>Controller: render Tasks page shell + Controller->>Controller: resolve title, router mode, mobile check + Controller->>Controller: load ScreenBuilderManager scripts + Controller->>Controller: load task filter, default columns, drafts flag + Controller->>Controller: load user configuration and default saved search + Controller-->>TasksPageEtagMw: tasks.index response + TasksPageEtagMw->>TasksPageEtagMw: attach weak ETag to 200 response + TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate + TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires + TasksPageEtagMw-->>BrowserCache: 200 OK with weak ETag + end + end + + alt Response has ETag + BrowserCache->>BrowserCache: preserve response headers + BrowserCache->>BrowserCache: skip no-store / Pragma override + else No ETag and BROWSER_CACHE=false + BrowserCache->>BrowserCache: add Pragma: no-cache + BrowserCache->>BrowserCache: add Cache-Control: no-store + end + + BrowserCache-->>Browser: 200 OK + ETag or 304 Not Modified + Browser->>Browser: Store private validator and send If-None-Match on later reload +``` + +## ETag Context + +The payload intentionally includes content-affecting values: + +- Route path/query/router state. +- Authenticated user id, update timestamp, locale, timezone, display fields, and admin status. +- Tenant id and tenant update timestamp when present. +- Permission table version, session permission snapshot, direct user/group assignment hashes, and direct group membership version. +- Task filter cache, user configuration hash, task draft flag, and saved search defaults hash. +- Page-relevant feature config, registered package list, package manifest, app version, `composer.lock`, and `mix-manifest.json`. + +The payload intentionally excludes volatile values that do not define the rendered page, such as CSRF token, session id, and randomized favicon URLs. + +## Header Outcome + +Successful Tasks page responses should use private revalidation rather than storage blocking: + +```http +Cache-Control: private, must-revalidate +ETag: W/"..." +Vary: Accept-Encoding +``` + +They should not include `no-store` or `Pragma: no-cache`; otherwise the browser will not keep the validator and will not send `If-None-Match`. diff --git a/package-lock.json b/package-lock.json index 235f58a839..1e437e51ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@processmaker/processmaker", - "version": "2026.16.2", + "version": "2026.17.1-p1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@processmaker/processmaker", - "version": "2026.16.2", + "version": "2026.17.1-p1", "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.50", + "@processmaker/modeler": "1.69.41-branch-bc19cd1c", "@processmaker/processmaker-bpmn-moddle": "0.16.3", - "@processmaker/screen-builder": "3.8.45", - "@processmaker/vue-form-elements": "0.65.13", + "@processmaker/screen-builder": "3.8.43-branch-5932fbd7", + "@processmaker/vue-form-elements": "0.65.11-branch-2c6a640", "@processmaker/vue-multiselect": "2.3.3", "@tinymce/tinymce-vue": "2.0.0", "axios": "^0.27.2", @@ -3817,9 +3817,9 @@ "license": "MIT" }, "node_modules/@processmaker/modeler": { - "version": "1.69.50", - "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.50.tgz", - "integrity": "sha512-MKtaQ3pxy1xoIdGUmVcUWw8S5WSkMR5DVAQzoOp+Hcw26QSkwqTwXjCMOanF6MuQn/Gj0BBc0qfS79h94PG32w==", + "version": "1.69.41-branch-bc19cd1c", + "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.41-branch-bc19cd1c.tgz", + "integrity": "sha512-omDvKKeRtE/Z16iYpClQAz0pj/hzUItJZYTVgVaa/kTr9evQdF91MVOz5L8skh6kCT7hfCq7wZhf9Pj52gkVCA==", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.12.1", "@fortawesome/fontawesome-free": "^5.11.2", @@ -3827,8 +3827,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.45", - "@processmaker/vue-form-elements": "0.65.13", + "@processmaker/screen-builder": "3.8.43-branch-5932fbd7", + "@processmaker/vue-form-elements": "0.65.11-branch-2c6a640", "@processmaker/vue-multiselect": "2.3.3", "bootstrap": "^4.3.1", "bootstrap-vue": "^2.0.4", @@ -4001,9 +4001,9 @@ } }, "node_modules/@processmaker/screen-builder": { - "version": "3.8.45", - "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.45.tgz", - "integrity": "sha512-+5l+5BYrvSSoWQRzYP8Cp22heW+Ko/TrecxQMPYx2sHM1uBnmGb6Su6Xg9JsQZXV1DD10McZLmQGPbsPGe2YGA==", + "version": "3.8.43-branch-5932fbd7", + "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.43-branch-5932fbd7.tgz", + "integrity": "sha512-dV+yYY2JO/y7IAj6USAGPMLUSGTmbVESDgv7tb5EMiVkB4QTnHCbF7Glth6QK3UVRYyZ+k8R1AJm9vJixyOvDQ==", "dependencies": { "@chantouchsek/validatorjs": "1.2.3", "@storybook/addon-docs": "^7.6.13", @@ -4028,7 +4028,7 @@ }, "peerDependencies": { "@panter/vue-i18next": "^0.15.0", - "@processmaker/vue-form-elements": "0.65.13", + "@processmaker/vue-form-elements": "0.65.11-branch-2c6a640", "i18next": "^15.0.8", "vue": "^2.6.12", "vuex": "^3.1.1" @@ -4077,9 +4077,9 @@ "license": "MIT" }, "node_modules/@processmaker/vue-form-elements": { - "version": "0.65.13", - "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.13.tgz", - "integrity": "sha512-FHmOwIr6TJY+btdbttuQTAubcRZ+46E6bmvCKe4GcbbcVoUnp0wKJVJlikje7+2o4VMNHN9GZpuKI13eHVJMJw==", + "version": "0.65.11-branch-2c6a640", + "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.11-branch-2c6a640.tgz", + "integrity": "sha512-aQmK2f5uArTzkXvsjo0/XTs/1cF/TDOzEsJf5R31x7leySYNTMioWysmmlzfbnvPwRg5KJ+vJwocbEmz3FiEDw==", "license": "MIT", "dependencies": { "@chantouchsek/validatorjs": "1.2.3", @@ -4481,18 +4481,18 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", - "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.2.1.tgz", + "integrity": "sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-roving-focus": "1.1.19", - "@radix-ui/react-separator": "1.1.15", - "@radix-ui/react-toggle-group": "1.1.19" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-separator": "1.2.0", + "@radix-ui/react-toggle-group": "1.2.1" }, "peerDependencies": { "@types/react": "*", @@ -4510,9 +4510,9 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/primitive": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", - "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", "license": "MIT" }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-compose-refs": { @@ -4531,9 +4531,9 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-context": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", - "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4546,9 +4546,9 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-direction": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", - "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz", + "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4579,12 +4579,12 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", - "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-slot": "1.5.0" }, "peerDependencies": { "@types/react": "*", @@ -4602,20 +4602,20 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", - "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz", + "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, @@ -4635,15 +4635,16 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", - "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz", + "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4661,12 +4662,12 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", - "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.2.0.tgz", + "integrity": "sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10" + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -4684,9 +4685,9 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", - "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" @@ -4702,18 +4703,18 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", - "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.2.1.tgz", + "integrity": "sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-roving-focus": "1.1.19", - "@radix-ui/react-toggle": "1.1.18", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-toggle": "1.2.1", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -4731,14 +4732,14 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-toggle": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", - "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.2.1.tgz", + "integrity": "sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -4756,10 +4757,13 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", - "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4771,12 +4775,12 @@ } }, "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", - "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, @@ -6567,9 +6571,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", - "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "version": "4.17.26", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", + "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", "license": "MIT" }, "node_modules/@types/mdx": { @@ -8843,10 +8847,10 @@ } }, "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "license": "Apache-2.0", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.2.tgz", + "integrity": "sha512-V2o8YS2EhrKMO9AumnkK0ynynfFlsajpYupwq7S46H5mhcanwxDkNTw3K67wPIcKTH1ffkA4vhMOtAc7gRmPZw==", + "license": "MIT", "dependencies": { "node-int64": "^0.4.0" } @@ -12231,12 +12235,12 @@ } }, "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "license": "Apache-2.0", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.4.tgz", + "integrity": "sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==", + "license": "MIT", "dependencies": { - "bser": "2.1.1" + "bser": "2.1.2" } }, "node_modules/figures": { @@ -13013,12 +13017,12 @@ "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -27222,9 +27226,9 @@ "dev": true }, "@processmaker/modeler": { - "version": "1.69.50", - "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.50.tgz", - "integrity": "sha512-MKtaQ3pxy1xoIdGUmVcUWw8S5WSkMR5DVAQzoOp+Hcw26QSkwqTwXjCMOanF6MuQn/Gj0BBc0qfS79h94PG32w==", + "version": "1.69.41-branch-bc19cd1c", + "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.41-branch-bc19cd1c.tgz", + "integrity": "sha512-omDvKKeRtE/Z16iYpClQAz0pj/hzUItJZYTVgVaa/kTr9evQdF91MVOz5L8skh6kCT7hfCq7wZhf9Pj52gkVCA==", "requires": { "@babel/plugin-proposal-private-methods": "^7.12.1", "@fortawesome/fontawesome-free": "^5.11.2", @@ -27232,8 +27236,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.45", - "@processmaker/vue-form-elements": "0.65.13", + "@processmaker/screen-builder": "3.8.43-branch-5932fbd7", + "@processmaker/vue-form-elements": "0.65.11-branch-2c6a640", "@processmaker/vue-multiselect": "2.3.3", "bootstrap": "^4.3.1", "bootstrap-vue": "^2.0.4", @@ -27348,9 +27352,9 @@ } }, "@processmaker/screen-builder": { - "version": "3.8.45", - "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.45.tgz", - "integrity": "sha512-+5l+5BYrvSSoWQRzYP8Cp22heW+Ko/TrecxQMPYx2sHM1uBnmGb6Su6Xg9JsQZXV1DD10McZLmQGPbsPGe2YGA==", + "version": "3.8.43-branch-5932fbd7", + "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.43-branch-5932fbd7.tgz", + "integrity": "sha512-dV+yYY2JO/y7IAj6USAGPMLUSGTmbVESDgv7tb5EMiVkB4QTnHCbF7Glth6QK3UVRYyZ+k8R1AJm9vJixyOvDQ==", "requires": { "@chantouchsek/validatorjs": "1.2.3", "@storybook/addon-docs": "^7.6.13", @@ -27404,9 +27408,9 @@ } }, "@processmaker/vue-form-elements": { - "version": "0.65.13", - "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.13.tgz", - "integrity": "sha512-FHmOwIr6TJY+btdbttuQTAubcRZ+46E6bmvCKe4GcbbcVoUnp0wKJVJlikje7+2o4VMNHN9GZpuKI13eHVJMJw==", + "version": "0.65.11-branch-2c6a640", + "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.11-branch-2c6a640.tgz", + "integrity": "sha512-aQmK2f5uArTzkXvsjo0/XTs/1cF/TDOzEsJf5R31x7leySYNTMioWysmmlzfbnvPwRg5KJ+vJwocbEmz3FiEDw==", "requires": { "@chantouchsek/validatorjs": "1.2.3", "@tinymce/tinymce-vue": "2.0.0", @@ -27616,23 +27620,23 @@ } }, "@radix-ui/react-toolbar": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", - "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.2.1.tgz", + "integrity": "sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==", "requires": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-roving-focus": "1.1.19", - "@radix-ui/react-separator": "1.1.15", - "@radix-ui/react-toggle-group": "1.1.19" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-separator": "1.2.0", + "@radix-ui/react-toggle-group": "1.2.1" }, "dependencies": { "@radix-ui/primitive": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", - "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==" }, "@radix-ui/react-compose-refs": { "version": "1.1.5", @@ -27641,15 +27645,15 @@ "requires": {} }, "@radix-ui/react-context": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", - "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", "requires": {} }, "@radix-ui/react-direction": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", - "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz", + "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==", "requires": {} }, "@radix-ui/react-id": { @@ -27661,98 +27665,101 @@ } }, "@radix-ui/react-primitive": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", - "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", "requires": { - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-slot": "1.5.0" } }, "@radix-ui/react-roving-focus": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", - "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz", + "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==", "requires": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "dependencies": { "@radix-ui/react-collection": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", - "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz", + "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==", "requires": { "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-layout-effect": "1.1.4" } } } }, "@radix-ui/react-separator": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", - "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.2.0.tgz", + "integrity": "sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==", "requires": { - "@radix-ui/react-primitive": "2.1.10" + "@radix-ui/react-primitive": "2.2.0" } }, "@radix-ui/react-slot": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", - "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", "requires": { "@radix-ui/react-compose-refs": "1.1.5" } }, "@radix-ui/react-toggle-group": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", - "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", - "requires": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-roving-focus": "1.1.19", - "@radix-ui/react-toggle": "1.1.18", - "@radix-ui/react-use-controllable-state": "1.2.6" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.2.1.tgz", + "integrity": "sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==", + "requires": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-toggle": "1.2.1", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "dependencies": { "@radix-ui/react-toggle": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", - "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.2.1.tgz", + "integrity": "sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==", "requires": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" } } } }, "@radix-ui/react-use-callback-ref": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", - "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", - "requires": {} + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.4" + } }, "@radix-ui/react-use-controllable-state": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", - "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", "requires": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" } @@ -28903,9 +28910,9 @@ } }, "@types/lodash": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", - "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==" + "version": "4.17.26", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", + "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==" }, "@types/mdx": { "version": "2.0.14", @@ -30678,9 +30685,9 @@ } }, "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.2.tgz", + "integrity": "sha512-V2o8YS2EhrKMO9AumnkK0ynynfFlsajpYupwq7S46H5mhcanwxDkNTw3K67wPIcKTH1ffkA4vhMOtAc7gRmPZw==", "requires": { "node-int64": "^0.4.0" } @@ -33246,11 +33253,11 @@ } }, "fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.4.tgz", + "integrity": "sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==", "requires": { - "bser": "2.1.1" + "bser": "2.1.2" } }, "figures": { @@ -33811,11 +33818,11 @@ "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" }, "handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "requires": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "uglify-js": "^3.1.4", diff --git a/package.json b/package.json index ad33d2d451..5d3c97ed9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@processmaker/processmaker", - "version": "2026.16.2", + "version": "2026.17.1-p1", "description": "ProcessMaker 4", "author": "DevOps ", "license": "ISC", @@ -68,10 +68,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.50", + "@processmaker/modeler": "1.69.41-branch-bc19cd1c", "@processmaker/processmaker-bpmn-moddle": "0.16.3", - "@processmaker/screen-builder": "3.8.45", - "@processmaker/vue-form-elements": "0.65.13", + "@processmaker/screen-builder": "3.8.43-branch-5932fbd7", + "@processmaker/vue-form-elements": "0.65.11-branch-2c6a640", "@processmaker/vue-multiselect": "2.3.3", "@tinymce/tinymce-vue": "2.0.0", "axios": "^0.27.2", @@ -163,6 +163,10 @@ "node": ">=16" }, "overrides": { - "webpack": "5.88.2" + "webpack": "5.88.2", + "@processmaker/modeler": { + "@processmaker/screen-builder": "3.8.43-branch-5932fbd7", + "@processmaker/vue-form-elements": "0.65.11-branch-2c6a640" + } } } diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js new file mode 100644 index 0000000000..8b3dc7762c --- /dev/null +++ b/resources/js/apiClientCache.js @@ -0,0 +1,477 @@ +/** + * Default number of milliseconds a successful GET response remains reusable. + */ +const DEFAULT_CACHE_TTL = 5000; + +/** + * HTTP method eligible for response caching and request deduplication. + */ +const CACHEABLE_METHOD = "get"; + +/** + * Determines whether a value is a plain object-like value for parameter encoding. + * + * @param {*} value + * @returns {boolean} + */ +const isObject = (value) => value && typeof value === "object" && !Array.isArray(value); + +/** + * Checks whether a URL already includes a protocol and host. + * + * @param {string} url + * @returns {boolean} + */ +const isAbsoluteURL = (url) => /^[a-z][a-z\d+\-.]*:\/\//i.test(url); + +/** + * Combines Axios baseURL and request URL while preserving absolute request URLs. + * + * @param {string} baseURL + * @param {string} requestedURL + * @returns {string} + */ +const combineURLs = (baseURL, requestedURL = "") => { + const normalizedBaseURL = baseURL || ""; + + if (!normalizedBaseURL || isAbsoluteURL(requestedURL)) { + return requestedURL; + } + + return `${normalizedBaseURL.replace(/\/+$/, "")}/${requestedURL.replace(/^\/+/, "")}`; +}; + +/** + * Reads a header value case-insensitively from an Axios headers object. + * + * @param {object} headers + * @param {string} headerName + * @returns {*} + */ +const normalizeHeaderName = (headers, headerName) => { + const normalizedHeaders = headers || {}; + const match = Object.keys(normalizedHeaders).find((key) => key.toLowerCase() === headerName.toLowerCase()); + return match ? normalizedHeaders[match] : undefined; +}; + +/** + * Converts a query parameter value into a stable string representation. + * + * @param {*} value + * @returns {*} + */ +const encodeValue = (value) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + return value; +}; + +/** + * Serializes query parameters in a stable order unless Axios provides a custom serializer. + * + * @param {object|URLSearchParams} params + * @param {Function} paramsSerializer + * @returns {string} + */ +const serializeParams = (params, paramsSerializer) => { + if (!params) { + return ""; + } + + if (typeof paramsSerializer === "function") { + return paramsSerializer(params); + } + + if (typeof URLSearchParams !== "undefined" && params instanceof URLSearchParams) { + return params.toString(); + } + + return Object.keys(params) + .sort() + .reduce((parts, key) => { + const value = params[key]; + + if (value === null || typeof value === "undefined") { + return parts; + } + + const values = Array.isArray(value) ? value : [value]; + + values.forEach((entry) => { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(encodeValue(entry))}`); + }); + + return parts; + }, []) + .join("&"); +}; + +/** + * Appends serialized query parameters to a URL. + * + * @param {string} url + * @param {object|URLSearchParams} params + * @param {Function} paramsSerializer + * @returns {string} + */ +const appendParams = (url, params, paramsSerializer) => { + const serializedParams = serializeParams(params, paramsSerializer); + + if (!serializedParams) { + return url; + } + + return `${url}${url.includes("?") ? "&" : "?"}${serializedParams}`; +}; + +/** + * Clones response data so cached responses are not mutated by consumers. + * + * @param {*} data + * @returns {*} + */ +const cloneData = (data) => { + if (!data || typeof data !== "object") { + return data; + } + + if (typeof structuredClone === "function") { + try { + return structuredClone(data); + } catch (error) { + // Fall through to JSON cloning for plain response payloads. + } + } + + try { + return JSON.parse(JSON.stringify(data)); + } catch (error) { + return data; + } +}; + +/** + * Clones the Axios response fields that consumers commonly mutate. + * + * @param {object} response + * @returns {object} + */ +const cloneResponse = (response) => ({ + ...response, + data: cloneData(response.data), + headers: response.headers ? { ...response.headers } : response.headers, +}); + +/** + * Builds the cache lookup key and human-readable URL for an Axios request. + * + * The key includes URL, query parameters, response type, and Accept header so + * requests that can produce different payload shapes do not share cache entries. + * + * @param {object} config Axios request configuration + * @returns {{key: string, url: string}} + */ +export const buildApiClientCacheKey = (config = {}) => { + const url = appendParams( + combineURLs(config.baseURL, config.url || ""), + config.params, + config.paramsSerializer, + ); + const headers = config.headers || {}; + const relevantConfig = { + responseType: config.responseType || "", + accept: normalizeHeaderName(headers, "Accept") || "", + }; + + return { + key: `${url}|${JSON.stringify(relevantConfig)}`, + url, + }; +}; + +/** + * Installs response caching and in-flight request deduplication on an Axios client. + * + * The wrapper is implemented at the Axios adapter layer so normal Axios call + * forms, interceptors, defaults, and helpers continue to work unchanged. + * + * @param {Function|object} apiClient Axios client instance + * @returns {Function|object} The same Axios client instance + */ +export const installApiClientCache = (apiClient) => { + if (!apiClient || apiClient.cache) { + return apiClient; + } + + const client = apiClient; + const responseCache = new Map(); + const pendingRequests = new Map(); + const originalAdapter = client.defaults.adapter; + + let globallyEnabled = false; + let disabled = false; + let debugEnabled = false; + + /** + * Writes cache diagnostics only when global or per-request debugging is enabled. + * + * @param {object} config Axios request configuration + * @param {string} message Debug message + * @param {object} details Structured details for browser console inspection + * @returns {void} + */ + const debug = (config, message, details = {}) => { + if (!debugEnabled && !(config.cache && config.cache.debug)) { + return; + } + + // eslint-disable-next-line no-console + console.log(`[ProcessMaker.apiClient.cache] ${message}`, details); + }; + + /** + * Removes expired response entries from the in-memory cache. + * + * @returns {void} + */ + const cleanup = () => { + const now = Date.now(); + + responseCache.forEach((entry, key) => { + if (entry.expiresAt <= now) { + responseCache.delete(key); + } + }); + }; + + /** + * Invalidates response entries that satisfy a caller-provided predicate. + * + * @param {Function} matcher Predicate receiving the cache key and entry + * @returns {void} + */ + const invalidateByMatcher = (matcher) => { + responseCache.forEach((entry, key) => { + if (matcher(key, entry)) { + responseCache.delete(key); + } + }); + }; + + const cache = { + DEFAULT_CACHE_TTL, + /** + * Indicates whether cache is enabled globally for requests that do not opt in. + * + * @returns {boolean} + */ + get enabled() { + return globallyEnabled && !disabled; + }, + /** + * Indicates whether cache has been disabled for the current window. + * + * @returns {boolean} + */ + get disabled() { + return disabled; + }, + /** + * Indicates whether global cache debug logging is active. + * + * @returns {boolean} + */ + get debug() { + return debugEnabled; + }, + /** + * Enables cache for all eligible GET requests in the current window. + * + * @returns {void} + */ + enable() { + globallyEnabled = true; + disabled = false; + debug({}, "cache enabled globally"); + }, + /** + * Disables cache and deduplication for the current window. + * + * @returns {void} + */ + disable() { + disabled = true; + debug({}, "cache disabled for current window"); + }, + /** + * Enables cache diagnostic logging for the current window. + * + * @returns {void} + */ + enableDebug() { + debugEnabled = true; + debug({}, "debug logging enabled"); + }, + /** + * Disables cache diagnostic logging for the current window. + * + * @returns {void} + */ + disableDebug() { + debug({}, "debug logging disabled"); + debugEnabled = false; + }, + /** + * Clears cached responses and tracked in-flight requests. + * + * @returns {void} + */ + clear() { + responseCache.clear(); + pendingRequests.clear(); + debug({}, "cache cleared"); + }, + cleanup, + /** + * Invalidates an exact cache key or URL. + * + * @param {string} urlOrKey + * @returns {void} + */ + invalidate(urlOrKey) { + invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); + debug({}, "cache invalidated", { urlOrKey }); + }, + /** + * Invalidates cache entries whose key or URL matches a string or RegExp pattern. + * + * @param {string|RegExp} pattern + * @returns {void} + */ + invalidateByPattern(pattern) { + if (pattern instanceof RegExp) { + invalidateByMatcher((key, entry) => pattern.test(key) || pattern.test(entry.url)); + debug({}, "cache invalidated by RegExp pattern", { pattern }); + return; + } + + invalidateByMatcher((key, entry) => key.includes(pattern) || entry.url.includes(pattern)); + debug({}, "cache invalidated by pattern", { pattern }); + }, + }; + + /** + * Determines whether a request should use cache behavior. + * + * @param {object} config Axios request configuration + * @returns {boolean} + */ + const isCacheEnabledForRequest = (config) => { + if (disabled || (config.cache && config.cache.enabled === false)) { + return false; + } + + return Boolean(config.cache && config.cache.enabled === true) || globallyEnabled; + }; + + /** + * Resolves the per-request TTL, falling back to the default duration. + * + * @param {object} config Axios request configuration + * @returns {number} + */ + const getTTL = (config) => { + const ttl = Number(config.cache && config.cache.ttl); + return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_CACHE_TTL; + }; + + client.DEFAULT_CACHE_TTL = DEFAULT_CACHE_TTL; + client.cache = cache; + /** + * Axios adapter wrapper that serves cached GETs, joins duplicate in-flight GETs, + * or delegates to the original adapter for all other requests. + * + * @param {object} config Axios request configuration + * @returns {Promise} + */ + client.defaults.adapter = (config) => { + const method = (config.method || CACHEABLE_METHOD).toLowerCase(); + + if (method !== CACHEABLE_METHOD || !isCacheEnabledForRequest(config)) { + debug(config, "bypassing cache", { + method, + url: config.url, + reason: method !== CACHEABLE_METHOD ? "non-get request" : "cache disabled", + }); + return originalAdapter(config); + } + + cleanup(); + + const { key, url } = buildApiClientCacheKey(config); + const cachedResponse = responseCache.get(key); + + if (cachedResponse && cachedResponse.expiresAt > Date.now()) { + debug(config, "cache hit", { + key, + url, + expiresAt: cachedResponse.expiresAt, + }); + return Promise.resolve(cloneResponse(cachedResponse.response)); + } + + if (pendingRequests.has(key)) { + debug(config, "deduplicated in-flight request", { key, url }); + return pendingRequests.get(key); + } + + debug(config, "cache miss; sending network request", { key, url }); + + const request = originalAdapter(config) + .then((response) => { + responseCache.set(key, { + key, + url, + response: cloneResponse(response), + expiresAt: Date.now() + getTTL(config), + }); + + debug(config, "response cached", { + key, + url, + ttl: getTTL(config), + status: response.status, + }); + + return cloneResponse(response); + }) + .catch((error) => { + debug(config, "request failed; response not cached", { + key, + url, + message: error.message, + status: error.response && error.response.status, + }); + + return Promise.reject(error); + }) + .finally(() => { + pendingRequests.delete(key); + debug(config, "in-flight request removed", { key, url }); + }); + + pendingRequests.set(key, request); + + return request; + }; + + return client; +}; + +export default installApiClientCache; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 0b9352f608..8cc2e20143 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -40,6 +40,7 @@ import TreeView from "./components/TreeView.vue"; import FilterTable from "./components/shared/FilterTable.vue"; import PaginationTable from "./components/shared/PaginationTable.vue"; import PMDropdownSuggest from "./components/PMDropdownSuggest"; +import { installApiClientCache } from "./apiClientCache"; import "@processmaker/screen-builder/dist/vue-form-builder.css"; import Echo from "laravel-echo"; import Pusher from "pusher-js"; @@ -251,6 +252,7 @@ window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); window.ProcessMaker.apiClient = require("axios"); window.ProcessMaker.apiClient.defaults.withCredentials = true; +installApiClientCache(window.ProcessMaker.apiClient); window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; diff --git a/resources/js/next/config/processmaker.js b/resources/js/next/config/processmaker.js index abc541da0d..cbb43d7368 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -6,6 +6,7 @@ import { attachSessionRenewalInterceptor, getCsrfToken, } from "../../common/csrfToken"; +import { installApiClientCache } from "../../apiClientCache"; export default () => { const token = document.head.querySelector("meta[name=\"csrf-token\"]"); @@ -26,6 +27,7 @@ export default () => { */ const apiClient = axios; + installApiClientCache(apiClient); // Laravel web sessions / CSRF with cookie requires axios to send cookies. apiClient.defaults.withCredentials = true; diff --git a/resources/js/processes/components/ProcessMixin.js b/resources/js/processes/components/ProcessMixin.js index 41e0ccad0c..1981a9a749 100644 --- a/resources/js/processes/components/ProcessMixin.js +++ b/resources/js/processes/components/ProcessMixin.js @@ -91,8 +91,7 @@ export default { this.orderBy }&order_direction=${ this.orderDirection - }&include=categories,category,user` - + "&with=events", + }&include=categories,category,user`, { cancelToken: new CancelToken((c) => { this.cancelToken = c; diff --git a/resources/js/tasks/edit.js b/resources/js/tasks/edit.js index 0b288940e7..43deebb6c9 100644 --- a/resources/js/tasks/edit.js +++ b/resources/js/tasks/edit.js @@ -66,6 +66,10 @@ const main = new Vue({ userConfiguration: window.temporal.userConfiguration, urlConfiguration: "users/configuration", showTabs: true, + loadedTabs: { + form: true, + data: false, + }, }, computed: { taskDefinitionConfig() { @@ -174,6 +178,14 @@ const main = new Vue({ this.setAllowReassignment(); }, methods: { + openDataTab() { + if (!this.loadedTabs.data) { + this.loadedTabs.data = true; + } + this.$nextTick(() => { + this.resizeMonaco(); + }); + }, defineUserConfiguration() { this.userConfiguration = JSON.parse(this.userConfiguration.ui_configuration); this.showMenu = this.userConfiguration.tasks.isMenuCollapse; @@ -290,8 +302,13 @@ const main = new Vue({ }, resizeMonaco() { this.showTree = false; - const editor = this.$refs.monaco.getMonaco(); - editor.layout({ height: window.innerHeight * 0.65 }); + const editor = this.$refs.monaco?.getMonaco?.(); + if (!editor) { + return; + } + editor.layout({ + height: window.innerHeight * 0.65, + }); }, prepareData() { this.updateRequestData = debounce(this.updateRequestData, 1000); diff --git a/resources/views/auth/newLogin.blade.php b/resources/views/auth/newLogin.blade.php index 0365a8b51a..d5a6af9abb 100644 --- a/resources/views/auth/newLogin.blade.php +++ b/resources/views/auth/newLogin.blade.php @@ -18,6 +18,13 @@ @include('package-accessibility::userway') @endif + {{-- Prefetch inbox JS while the user is on login (Mix ?id= invalidates on deploy). --}} + + + + + +
diff --git a/resources/views/layouts/layoutnext.blade.php b/resources/views/layouts/layoutnext.blade.php index 5516ab138b..c10d440700 100644 --- a/resources/views/layouts/layoutnext.blade.php +++ b/resources/views/layouts/layoutnext.blade.php @@ -95,6 +95,7 @@ {!! config('global_header') !!} @endif + @stack('preload') @{{ __('Skip to Content') }} diff --git a/resources/views/tasks/edit.blade.php b/resources/views/tasks/edit.blade.php index 672a330a22..16f329f93d 100644 --- a/resources/views/tasks/edit.blade.php +++ b/resources/views/tasks/edit.blade.php @@ -27,6 +27,14 @@ function() use ($task) { ], 'attributes' => 'v-cloak']) @endsection @section('content') +@push('preload') + + + + + + +@endpush