From 33ebad60ba8a729fda81cd65472ff68b430131d4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Sep 2025 15:21:01 -0400 Subject: [PATCH 01/32] feat: add saved_search_advanced_configuration to filesystem configuration --- .gitignore | 1 + ProcessMaker/Multitenancy/SwitchTenant.php | 2 ++ config/filesystems.php | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index fcf4007765..f375b4a8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ storage/ssl storage/api/* storage/data-sources/logs/* storage/decision-tables/* +storage/saved_search_advanced_configuration/* npm.sh laravel-echo-server.lock public/.htaccess diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index 2ca2efc3ec..dfc5332b35 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -76,6 +76,8 @@ public function makeCurrent(IsTenant $tenant): void 'filesystems.disks.samlidp.root' => storage_path('samlidp'), 'filesystems.disks.decision_tables.root' => storage_path('decision-tables'), 'filesystems.disks.decision_tables.url' => $tenant->config['app.url'] . '/storage/decision-tables', + 'filesystems.disks.saved_search_advanced_configuration.root' => storage_path('saved_search_advanced_configuration'), + 'filesystems.disks.decision_tables.url' => $tenant->config['app.url'] . '/storage/saved_search_advanced_configuration', 'filesystems.disks.tenant_translations' => [ 'driver' => 'local', 'root' => storage_path('lang'), diff --git a/config/filesystems.php b/config/filesystems.php index 182dde1afe..a480cecde9 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -131,6 +131,13 @@ // Others declared in packages // - translations - package-translations // - 'filesystems.disks.install' configured on the fly + + 'saved_search_advanced_configuration' => [ + 'driver' => 'local', + 'root' => storage_path('saved_search_advanced_configuration'), + 'url' => env('APP_URL') . '/storage/saved_search_advanced_configuration', + 'visibility' => 'private', + ], ], /* From bf587d1d0eaaffb7e0c20b903bd3ec1b32fa7d0f Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Tue, 16 Sep 2025 18:31:15 -0400 Subject: [PATCH 02/32] Add job validation and new scheduling method `scheduleDateJob` --- .../Managers/TaskSchedulerManager.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index d59dc4db80..e459bd2cee 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; +use InvalidArgumentException; use PDOException; use ProcessMaker\Facades\WorkflowManager; use ProcessMaker\Jobs\StartEventConditional; @@ -574,6 +575,9 @@ public function scheduleCycle( */ public function scheduleCycleJob($interval, array $config): ScheduledTask { + if (!isset($config['job'])) { + throw new InvalidArgumentException('$config["job"] is required'); + } $configuration = [ 'type' => 'TimeCycle', 'interval' => $interval, @@ -590,6 +594,33 @@ public function scheduleCycleJob($interval, array $config): ScheduledTask return $scheduledTask; } + /** + * Schedule a job for a specific datetime + * + * @param string $datetime in ISO-8601 format + * @param array $config configuration + * + * @return ScheduledTask + */ + public function scheduleDateJob($datetime, array $config): ScheduledTask + { + if (!isset($config['job'])) { + throw new InvalidArgumentException('$config["job"] is required'); + } + $configuration = [ + 'type' => 'TimeDate', + 'date' => $datetime, + ...$config, + ]; + $scheduledTask = new ScheduledTask(); + $scheduledTask->configuration = json_encode($configuration); + $scheduledTask->type = 'SCHEDULED_JOB'; + $scheduledTask->last_execution = null; + $scheduledTask->save(); + + return $scheduledTask; + } + /** * Schedule a job execution after a time duration for the given BPMN element, * event definition and an optional Token object From 2b3db57ecbe6fb98786b4258f55991705dd02980 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 6 May 2026 11:57:48 -0400 Subject: [PATCH 03/32] feat: update configuration structure for scheduled jobs --- ProcessMaker/Managers/TaskSchedulerManager.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index 5d36c75e28..b12c3049da 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -202,10 +202,11 @@ private function processTaskWithAtomicClaim(ScheduledTask $task, DateTime $today { try { $config = json_decode($task->configuration); - $lastExecution = new DateTime($task->last_execution, new DateTimeZone('UTC')); - if ($lastExecution === null) { - return; + // SCHEDULED_JOB rows use last_execution = null until first run; BPMN timers always set last_execution. + $lastExecution = null; + if ($task->last_execution !== null && $task->last_execution !== '') { + $lastExecution = new DateTime($task->last_execution, new DateTimeZone('UTC')); } $owner = $task->processRequestToken ?: $task->processRequest ?: $task->process; @@ -721,11 +722,14 @@ public function scheduleDateJob($datetime, array $config): ScheduledTask if (!isset($config['job'])) { throw new InvalidArgumentException('$config["job"] is required'); } + + // Must use "interval" so nextDate(TimeDate) picks up the target datetime (same shape as BPMN timer tasks). $configuration = [ 'type' => 'TimeDate', - 'date' => $datetime, ...$config, + 'interval' => $datetime, ]; + $scheduledTask = new ScheduledTask(); $scheduledTask->configuration = json_encode($configuration); $scheduledTask->type = 'SCHEDULED_JOB'; From 8d15b575049ad97c7d0fae32c0cbf4b261658e4d Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 10 Jun 2026 09:34:45 -0700 Subject: [PATCH 04/32] Update dependency --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5fe0d8c2c3..02c3a6260a 100644 --- a/composer.json +++ b/composer.json @@ -176,7 +176,7 @@ "package-product-analytics": "1.5.11", "package-projects": "1.12.9", "package-rpa": "1.1.2", - "package-savedsearch": "1.43.13", + "package-savedsearch": "1.43.12-RC1", "package-slideshow": "1.4.3", "package-smart-extract": "0.0.6", "package-signature": "1.15.5", From 1444838ce5d335b412c1e7e4a337a89482625a46 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 10 Jun 2026 09:38:26 -0700 Subject: [PATCH 05/32] Version 2026.9.4-RC1 --- composer.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 02c3a6260a..0c89adfb08 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "processmaker/processmaker", - "version": "2026.9.3", + "version": "2026.9.4-RC1", "description": "BPM PHP Software", "keywords": [ "php bpm processmaker" diff --git a/package-lock.json b/package-lock.json index 169b6cc347..f95f9faf2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@processmaker/processmaker", - "version": "2026.9.3", + "version": "2026.9.4-RC1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@processmaker/processmaker", - "version": "2026.9.3", + "version": "2026.9.4-RC1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index b73db6ff33..abc5983b1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@processmaker/processmaker", - "version": "2026.9.3", + "version": "2026.9.4-RC1", "description": "ProcessMaker 4", "author": "DevOps ", "license": "ISC", From e04999c065ee0c5201fe77f48a25b01c9134cc66 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:35:05 -0400 Subject: [PATCH 06/32] feat: implement ETag middleware for Tasks page caching --- .../Http/Middleware/Etag/TasksPageEtag.php | 77 +++++ .../Http/Resources/Caching/TasksPageEtag.php | 303 ++++++++++++++++++ bootstrap/app.php | 1 + 3 files changed, 381 insertions(+) create mode 100644 ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php create mode 100644 ProcessMaker/Http/Resources/Caching/TasksPageEtag.php 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..82340702c5 --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php @@ -0,0 +1,303 @@ +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' => [ + 'default_columns' => DefaultColumns::get('tasks'), + '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. + */ + private function permissionsVersion(?User $user): ?array + { + if (!$user) { + return null; + } + + $permissions = $user->is_administrator + ? Permission::query()->pluck('name')->all() + : app(PermissionServiceManager::class)->getUserPermissions($user->id); + sort($permissions); + + return [ + 'is_administrator' => $user->is_administrator, + 'permissions' => $permissions, + 'session_permissions' => $this->sessionPermissions(), + 'groups_updated_at' => $this->dateValue( + GroupMember::where('member_type', User::class) + ->where('member_id', $user->id) + ->max('updated_at') + ), + ]; + } + + /** + * 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' => $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::where('user_id', $user->id)->first(); + if (!$configuration) { + return [ + 'updated_at' => null, + 'ui_configuration' => UserConfigurationController::DEFAULT_USER_CONFIGURATION, + ]; + } + + return [ + 'updated_at' => $this->dateValue($configuration->updated_at), + 'ui_configuration' => $configuration->ui_configuration, + ]; + } + + /** + * 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/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, From f6f6d041736f2c25e80e0ed0d3efa052da596e32 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:35:39 -0400 Subject: [PATCH 07/32] feat: update inbox and tasks routes to use ETag middleware for improved caching --- routes/web.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/web.php b/routes/web.php index c0cf1141bf..f14bd63f7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,7 +170,10 @@ Route::get('modeler/{process}/inflight/{request?}', [ModelerController::class, 'inflight'])->name('modeler.inflight')->middleware('can:view,request'); Route::get('/', [HomeController::class, 'index'])->name('home'); - Route::get('/inbox/{router?}', [TaskController::class, 'index'])->where(['router' => '.*'])->name('inbox')->middleware('no-cache'); + Route::get('/inbox/{router?}', [TaskController::class, 'index']) + ->where(['router' => '.*']) + ->name('inbox') + ->middleware('tasks-page-etag'); Route::get('/redirect-to-intended', [HomeController::class, 'redirectToIntended'])->name('redirect_to_intended'); Route::post('/keep-alive', [LoginController::class, 'keepAlive'])->name('keep-alive'); @@ -206,7 +209,7 @@ Route::get('tasks/search', [TaskController::class, 'search'])->name('tasks.search'); Route::get('tasks', [TaskController::class, 'index']) ->name('tasks.index') - ->middleware('no-cache'); + ->middleware('tasks-page-etag'); Route::get('tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit'); Route::get('tasks/{task}/edit/quickfill', [TaskController::class, 'quickFillEdit'])->name('tasks.edit.quickfill'); Route::get('tasks/{task}/edit/{preview}', [TaskController::class, 'edit'])->name('tasks.preview'); From df83b960399e7afcb2662b9aaceadfd0ac6633ce Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:35:56 -0400 Subject: [PATCH 08/32] feat: enhance BrowserCache middleware to return response if ETag header is present --- ProcessMaker/Http/Middleware/BrowserCache.php | 4 ++++ 1 file changed, 4 insertions(+) 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'); From 7576098b613c3839428a11d51e4c935edb148b9c Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:36:39 -0400 Subject: [PATCH 09/32] test: add ETag handling tests for Tasks page to ensure proper caching behavior --- tests/Feature/TasksTest.php | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/Feature/TasksTest.php b/tests/Feature/TasksTest.php index e63b2bb18d..6fb7aa3b57 100644 --- a/tests/Feature/TasksTest.php +++ b/tests/Feature/TasksTest.php @@ -7,6 +7,7 @@ use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\ProcessTaskAssignment; use ProcessMaker\Models\User; +use ProcessMaker\Models\UserConfiguration; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -39,6 +40,79 @@ public function testIndex() $response->assertSee('Tasks'); } + public function testTasksPageSendsPrivateEtagHeaders() + { + $response = $this->webGet(self::TASKS_URL, []); + + $response->assertStatus(200); + $response->assertHeader('ETag'); + $this->assertCacheControlHasPrivateMustRevalidate($response); + $response->assertHeaderMissing('Pragma'); + $response->assertHeaderMissing('Expires'); + } + + public function testTasksPageReturnsNotModifiedWhenEtagMatches() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + $responseWithMatchingEtag = $this->actingAs($this->user, 'web') + ->withHeaders(['If-None-Match' => $etag]) + ->get(self::TASKS_URL); + + $responseWithMatchingEtag->assertStatus(304); + $this->assertEquals( + $this->stripWeakEtagPrefix($etag), + $this->stripWeakEtagPrefix($responseWithMatchingEtag->headers->get('ETag')) + ); + $this->assertCacheControlHasPrivateMustRevalidate($responseWithMatchingEtag); + $this->assertEmpty($responseWithMatchingEtag->getContent()); + } + + public function testTasksPageEtagChangesWhenUserConfigurationChanges() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + UserConfiguration::create([ + 'user_id' => $this->user->id, + 'ui_configuration' => json_encode([ + 'tasks' => [ + 'isMenuCollapse' => false, + ], + ]), + ]); + + $updatedResponse = $this->webGet(self::TASKS_URL, []); + + $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag')); + } + + public function testTasksPageEtagChangesWhenFeatureConfigChanges() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + config()->set('app.task_drafts_enabled', !config('app.task_drafts_enabled')); + + $updatedResponse = $this->webGet(self::TASKS_URL, []); + + $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag')); + } + + private function assertCacheControlHasPrivateMustRevalidate($response): void + { + $cacheControl = $response->headers->get('Cache-Control'); + + $this->assertStringContainsString('private', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + private function stripWeakEtagPrefix(?string $etag): ?string + { + return $etag ? str_replace('W/', '', $etag) : null; + } + public function testViewTaskWithComments() { //Start a process request From 8b3959f9d9fc03bddae41b7f84663ee6886e2a47 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 11 Jun 2026 13:45:36 -0400 Subject: [PATCH 10/32] feat: optimize TasksPageEtag for permission handling and user configuration --- .../Http/Resources/Caching/TasksPageEtag.php | 106 +++++++++++++++--- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php index 82340702c5..298f8d0b81 100644 --- a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php +++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php @@ -5,17 +5,15 @@ use Illuminate\Foundation\PackageManifest; use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use ProcessMaker\Filters\SaveSession; -use ProcessMaker\Helpers\DefaultColumns; use ProcessMaker\Http\Controllers\Api\UserConfigurationController; use ProcessMaker\Managers\PackageManager; -use ProcessMaker\Models\GroupMember; -use ProcessMaker\Models\Permission; +use ProcessMaker\Models\Group; use ProcessMaker\Models\TaskDraft; use ProcessMaker\Models\User; use ProcessMaker\Models\UserConfiguration; -use ProcessMaker\Services\PermissionServiceManager; class TasksPageEtag { @@ -74,7 +72,6 @@ private function payload(Request $request): array 'saved_search_v' => $this->savedSearchPayload($user), 'locale' => app()->getLocale(), 'task_context' => [ - 'default_columns' => DefaultColumns::get('tasks'), 'user_filter' => $user ? SaveSession::getConfigFilter('taskFilter', $user) : null, 'user_configuration' => $this->userConfigurationPayload($user), 'task_drafts_enabled' => TaskDraft::draftsEnabled(), @@ -126,6 +123,10 @@ private function tenantPayload(): ?array /** * 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 { @@ -133,19 +134,18 @@ private function permissionsVersion(?User $user): ?array return null; } - $permissions = $user->is_administrator - ? Permission::query()->pluck('name')->all() - : app(PermissionServiceManager::class)->getUserPermissions($user->id); - sort($permissions); + $directGroups = $this->directGroupPayload($user); return [ 'is_administrator' => $user->is_administrator, - 'permissions' => $permissions, 'session_permissions' => $this->sessionPermissions(), - 'groups_updated_at' => $this->dateValue( - GroupMember::where('member_type', User::class) - ->where('member_id', $user->id) - ->max('updated_at') + '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'] ), ]; } @@ -184,7 +184,7 @@ private function savedSearchPayload(?User $user): ?array return [ 'id' => $savedSearch->id, 'updated_at' => $this->dateValue($savedSearch->updated_at), - 'columns' => $savedSearch->columns, + 'columns_hash' => $this->hashValue($savedSearch->columns), ]; } @@ -197,20 +197,90 @@ private function userConfigurationPayload(?User $user): array return UserConfigurationController::DEFAULT_USER_CONFIGURATION; } - $configuration = UserConfiguration::where('user_id', $user->id)->first(); + $configuration = UserConfiguration::select('updated_at', 'ui_configuration') + ->where('user_id', $user->id) + ->first(); if (!$configuration) { return [ 'updated_at' => null, - 'ui_configuration' => UserConfigurationController::DEFAULT_USER_CONFIGURATION, + 'ui_configuration_hash' => $this->hashValue(UserConfigurationController::DEFAULT_USER_CONFIGURATION), ]; } return [ 'updated_at' => $this->dateValue($configuration->updated_at), - 'ui_configuration' => $configuration->ui_configuration, + '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. */ From 6da1dec6c892abd5a5e42256d563b7b985f80713 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 11 Jun 2026 13:56:01 -0400 Subject: [PATCH 11/32] fix: update inbox route to use no-cache middleware for improved response handling --- routes/web.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/routes/web.php b/routes/web.php index f14bd63f7e..a8ca8fbb07 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,10 +170,7 @@ Route::get('modeler/{process}/inflight/{request?}', [ModelerController::class, 'inflight'])->name('modeler.inflight')->middleware('can:view,request'); Route::get('/', [HomeController::class, 'index'])->name('home'); - Route::get('/inbox/{router?}', [TaskController::class, 'index']) - ->where(['router' => '.*']) - ->name('inbox') - ->middleware('tasks-page-etag'); + Route::get('/inbox/{router?}', [TaskController::class, 'index'])->where(['router' => '.*'])->name('inbox')->middleware('no-cache'); Route::get('/redirect-to-intended', [HomeController::class, 'redirectToIntended'])->name('redirect_to_intended'); Route::post('/keep-alive', [LoginController::class, 'keepAlive'])->name('keep-alive'); From 127f020d99310ef8b8701b3699711854d5661414 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 11 Jun 2026 13:57:32 -0400 Subject: [PATCH 12/32] docs: add Tasks Page ETag Sequence documentation --- docs/tasks-page-etag-sequence.md | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/tasks-page-etag-sequence.md 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`. From 5c9d5b67e2b0c845e90b18c7097e15366206a21d Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 18 Jun 2026 16:01:37 -0400 Subject: [PATCH 13/32] feat: add api client caching functionality --- resources/js/apiClientCache.js | 246 +++++++++++++++++++++++ resources/js/bootstrap.js | 3 + resources/js/next/config/processmaker.js | 2 + 3 files changed, 251 insertions(+) create mode 100644 resources/js/apiClientCache.js diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js new file mode 100644 index 0000000000..942c3201fc --- /dev/null +++ b/resources/js/apiClientCache.js @@ -0,0 +1,246 @@ +const DEFAULT_CACHE_TTL = 5000; + +const CACHEABLE_METHOD = "get"; + +const isObject = (value) => value && typeof value === "object" && !Array.isArray(value); + +const isAbsoluteURL = (url) => /^[a-z][a-z\d+\-.]*:\/\//i.test(url); + +const combineURLs = (baseURL, requestedURL = "") => { + const normalizedBaseURL = baseURL || ""; + + if (!normalizedBaseURL || isAbsoluteURL(requestedURL)) { + return requestedURL; + } + + return `${normalizedBaseURL.replace(/\/+$/, "")}/${requestedURL.replace(/^\/+/, "")}`; +}; + +const normalizeHeaderName = (headers, headerName) => { + const normalizedHeaders = headers || {}; + const match = Object.keys(normalizedHeaders).find((key) => key.toLowerCase() === headerName.toLowerCase()); + return match ? normalizedHeaders[match] : undefined; +}; + +const encodeValue = (value) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + return value; +}; + +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("&"); +}; + +const appendParams = (url, params, paramsSerializer) => { + const serializedParams = serializeParams(params, paramsSerializer); + + if (!serializedParams) { + return url; + } + + return `${url}${url.includes("?") ? "&" : "?"}${serializedParams}`; +}; + +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; + } +}; + +const cloneResponse = (response) => ({ + ...response, + data: cloneData(response.data), + headers: response.headers ? { ...response.headers } : response.headers, +}); + +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, + }; +}; + +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; + + const cleanup = () => { + const now = Date.now(); + + responseCache.forEach((entry, key) => { + if (entry.expiresAt <= now) { + responseCache.delete(key); + } + }); + }; + + const invalidateByMatcher = (matcher) => { + responseCache.forEach((entry, key) => { + if (matcher(key, entry)) { + responseCache.delete(key); + } + }); + }; + + const cache = { + DEFAULT_CACHE_TTL, + get enabled() { + return globallyEnabled && !disabled; + }, + get disabled() { + return disabled; + }, + enable() { + globallyEnabled = true; + disabled = false; + }, + disable() { + disabled = true; + }, + clear() { + responseCache.clear(); + pendingRequests.clear(); + }, + cleanup, + invalidate(urlOrKey) { + invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); + }, + invalidateByPattern(pattern) { + if (pattern instanceof RegExp) { + invalidateByMatcher((key, entry) => pattern.test(key) || pattern.test(entry.url)); + return; + } + + invalidateByMatcher((key, entry) => key.includes(pattern) || entry.url.includes(pattern)); + }, + }; + + const isCacheEnabledForRequest = (config) => { + if (disabled || (config.cache && config.cache.enabled === false)) { + return false; + } + + return Boolean(config.cache && config.cache.enabled === true) || globallyEnabled; + }; + + 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; + client.defaults.adapter = (config) => { + const method = (config.method || CACHEABLE_METHOD).toLowerCase(); + + if (method !== CACHEABLE_METHOD || !isCacheEnabledForRequest(config)) { + return originalAdapter(config); + } + + cleanup(); + + const { key, url } = buildApiClientCacheKey(config); + const cachedResponse = responseCache.get(key); + + if (cachedResponse && cachedResponse.expiresAt > Date.now()) { + return Promise.resolve(cloneResponse(cachedResponse.response)); + } + + if (pendingRequests.has(key)) { + return pendingRequests.get(key).then(cloneResponse); + } + + const request = originalAdapter(config) + .then((response) => { + responseCache.set(key, { + key, + url, + response: cloneResponse(response), + expiresAt: Date.now() + getTTL(config), + }); + + return cloneResponse(response); + }) + .finally(() => { + pendingRequests.delete(key); + }); + + pendingRequests.set(key, request); + + return request.then(cloneResponse); + }; + + return client; +}; + +export default installApiClientCache; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 8df87873b1..acce95c058 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -34,6 +34,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"; window.__ = translator; @@ -242,6 +243,8 @@ window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); */ window.ProcessMaker.apiClient = require("axios"); +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 348227533a..e30588a07b 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -1,5 +1,6 @@ import axios from "axios"; import { setGlobalPMVariables, getGlobalPMVariable } from "../globalVariables"; +import { installApiClientCache } from "../../apiClientCache"; export default () => { const token = document.head.querySelector("meta[name=\"csrf-token\"]"); @@ -20,6 +21,7 @@ export default () => { */ const apiClient = axios; + installApiClientCache(apiClient); apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; From 913769f54e560b1c0e7aecae5c10cfe4c8b79c48 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 18 Jun 2026 16:56:21 -0400 Subject: [PATCH 14/32] feat: add debug logging to api client cache for improved diagnostics --- resources/js/apiClientCache.js | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js index 942c3201fc..215f72e459 100644 --- a/resources/js/apiClientCache.js +++ b/resources/js/apiClientCache.js @@ -133,6 +133,16 @@ export const installApiClientCache = (apiClient) => { let globallyEnabled = false; let disabled = false; + let debugEnabled = false; + + 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); + }; const cleanup = () => { const now = Date.now(); @@ -160,28 +170,45 @@ export const installApiClientCache = (apiClient) => { get disabled() { return disabled; }, + get debug() { + return debugEnabled; + }, enable() { globallyEnabled = true; disabled = false; + debug({}, "cache enabled globally"); }, disable() { disabled = true; + debug({}, "cache disabled for current window"); + }, + enableDebug() { + debugEnabled = true; + debug({}, "debug logging enabled"); + }, + disableDebug() { + debug({}, "debug logging disabled"); + debugEnabled = false; }, clear() { responseCache.clear(); pendingRequests.clear(); + debug({}, "cache cleared"); }, cleanup, invalidate(urlOrKey) { invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); + debug({}, "cache invalidated", { urlOrKey }); }, 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 }); }, }; @@ -204,6 +231,11 @@ export const installApiClientCache = (apiClient) => { 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); } @@ -213,13 +245,21 @@ export const installApiClientCache = (apiClient) => { 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).then(cloneResponse); } + debug(config, "cache miss; sending network request", { key, url }); + const request = originalAdapter(config) .then((response) => { responseCache.set(key, { @@ -229,10 +269,28 @@ export const installApiClientCache = (apiClient) => { 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); From 6cf268b02f49da324edadb5fe76039e44616efeb Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 19 Jun 2026 08:18:43 -0400 Subject: [PATCH 15/32] feat(FOUR-31831): review Improve Task page loading --- .../Http/Controllers/TaskController.php | 34 ++++++++++++++----- resources/js/tasks/edit.js | 21 ++++++++++-- resources/views/layouts/layoutnext.blade.php | 1 + resources/views/tasks/edit.blade.php | 20 ++++++++--- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/ProcessMaker/Http/Controllers/TaskController.php b/ProcessMaker/Http/Controllers/TaskController.php index 213d9374ab..8feb3b7bdd 100755 --- a/ProcessMaker/Http/Controllers/TaskController.php +++ b/ProcessMaker/Http/Controllers/TaskController.php @@ -108,13 +108,25 @@ 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); + // $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 @@ -178,7 +190,10 @@ public function edit(ProcessRequestToken $task, string $preview = '') ]); } - UserResourceView::setViewed(Auth::user(), $task); + // UserResourceView::setViewed(Auth::user(), $task); + dispatch(function () use ($task) { + UserResourceView::setViewed(Auth::user(), $task); + })->afterResponse(); $currentUser = Auth::user()->only([ 'id', 'username', @@ -189,7 +204,8 @@ public function edit(ProcessRequestToken $task, string $preview = '') 'timezone', 'datetime_format', ]); - $userConfiguration = (new UserConfigurationController())->index(); + //$userConfiguration = (new UserConfigurationController())->index(); + $userConfiguration = app(UserConfigurationController::class)->index(); $hitlEnabled = config('smart-extract.hitl_enabled', false) && $isSmartExtractTask; // Build the iframe source @@ -209,9 +225,11 @@ public function edit(ProcessRequestToken $task, string $preview = '') $iframeSrc = $dashboardUrl . '?' . $queryParams; } } + $canUpdateTask = Auth::user()->can('update', $task); return view('tasks.edit', [ 'task' => $task, + 'canUpdateTask' => $canUpdateTask, 'dueLabels' => self::$dueLabels, 'manager' => $manager, 'submitUrl' => $submitUrl, 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 0524f2abf8..420b8cc024 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..e68c908541 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