diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 28844fe451..965c7e19eb 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; @@ -126,6 +127,9 @@ class TaskController extends Controller */ 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..7d0f388ab6 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php @@ -4,20 +4,33 @@ 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; class TaskController extends Controller { + use TaskControllerIndexMethods; + + public $doNotSanitize = [ + 'data', + 'pmql', + ]; + protected $defaultFields = [ 'id', 'element_id', @@ -29,6 +42,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 +214,23 @@ 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); + } } diff --git a/ProcessMaker/Http/Controllers/TaskController.php b/ProcessMaker/Http/Controllers/TaskController.php index 2672a3abf6..07877e88cb 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/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/TaskControllerIndexMethods.php b/ProcessMaker/Traits/TaskControllerIndexMethods.php index fd76a3065d..f4da4704bb 100644 --- a/ProcessMaker/Traits/TaskControllerIndexMethods.php +++ b/ProcessMaker/Traits/TaskControllerIndexMethods.php @@ -22,6 +22,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 @@ -63,6 +78,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', ''); @@ -161,6 +292,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); @@ -178,15 +310,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/config/app.php b/config/app.php index b3c5891f98..bd667959e6 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'), 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/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/tasks/edit.js b/resources/js/tasks/edit.js index d58fa5281b..9a4f6cc2ad 100644 --- a/resources/js/tasks/edit.js +++ b/resources/js/tasks/edit.js @@ -64,6 +64,10 @@ const main = new Vue({ userConfiguration, urlConfiguration: "users/configuration", showTabs: true, + loadedTabs: { + form: true, + data: false, + }, }, computed: { taskDefinitionConfig() { @@ -172,6 +176,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; @@ -288,8 +300,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/layouts/layoutnext.blade.php b/resources/views/layouts/layoutnext.blade.php index a78ca97aee..3b7357e035 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 8bd7af00fd..061402a1cf 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