From f600087e2999bc70ea28e0adb8aae8351aeb6a76 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 10 Sep 2026 12:21:54 -0400 Subject: [PATCH 1/4] feat: optimize notification settings retrieval in Process model --- ProcessMaker/Models/Process.php | 10 ++++------ ProcessMaker/Models/ProcessMakerModel.php | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index f7390f1aaa..5facefd20d 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -342,13 +342,13 @@ public function embed() public function getNotificationsAttribute() { $array = []; + $settings = $this->notification_settings->whereNull('element_id'); foreach ($this->requestNotifiableTypes as $notifiable) { foreach ($this->requestNotificationTypes as $notification) { - $setting = $this->notification_settings() - ->whereNull('element_id') + $setting = $settings ->where('notifiable_type', $notifiable) - ->where('notification_type', $notification)->get(); + ->where('notification_type', $notification); if ($setting->count()) { $value = true; @@ -372,9 +372,7 @@ public function getTaskNotificationsAttribute() { $array = []; - $elements = $this->notification_settings() - ->whereNotNull('element_id') - ->get(); + $elements = $this->notification_settings->whereNotNull('element_id'); foreach ($elements->groupBy('element_id') as $group) { $elementId = $group->first()->element_id; diff --git a/ProcessMaker/Models/ProcessMakerModel.php b/ProcessMaker/Models/ProcessMakerModel.php index 1fb77d2728..999bd82f6c 100644 --- a/ProcessMaker/Models/ProcessMakerModel.php +++ b/ProcessMaker/Models/ProcessMakerModel.php @@ -36,7 +36,7 @@ public function scopeExclude($query, array $columns) $columnsToShow = array_diff($this->getTableColumns(), $columns); $columnsToShow = array_map(function ($column) { - return $this->table . '.' . $column; + return $this->getTable() . '.' . $column; }, $columnsToShow); return $query->select($columnsToShow); From b36b8258d0f97299a8618d9a2c8b54f2bca63792 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 10 Sep 2026 12:43:52 -0400 Subject: [PATCH 2/4] feat: enhance process retrieval logic by refining relationship handling and optimizing pagination --- .../Controllers/Api/ProcessController.php | 67 ++++++------------- 1 file changed, 20 insertions(+), 47 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index 1578e8df17..5824a81bd3 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -129,17 +129,21 @@ public function index(Request $request) $user = Auth::user(); $orderBy = $this->getRequestSortBy($request, 'name'); + if (!str_contains($orderBy[0], '.')) { + $orderBy[0] = 'processes.' . $orderBy[0]; + } $perPage = $this->getPerPage($request); $include = $this->getRequestInclude($request); + $relationships = array_values(array_diff($include, ['events'])); $status = $request->input('status'); $pmql = $request->input('pmql', ''); - $processes = Process::nonSystem()->notArchived()->with($include); + $processes = Process::nonSystem()->notArchived()->with($relationships); if ($status === 'archived') { - $processes = Process::archived()->with($include); + $processes = Process::archived()->with($relationships); } if ($status === 'all') { - $processes = Process::active()->with($include); + $processes = Process::active()->with($relationships); } $filter = $request->input('filter', ''); @@ -188,60 +192,29 @@ public function index(Request $request) // Get with launchpad $launchpad = $request->input('launchpad', false); - $processes = $processes->with('events') - ->select('processes.*') + $processes = $processes + ->with('notification_settings') + ->exclude(['bpmn', 'svg']) ->leftJoin(\DB::raw('(select id, uuid, name from process_categories) as category'), 'processes.process_category_id', '=', 'category.id') ->leftJoin(\DB::raw('(select id, uuid, username, lastname, firstname from users) as user'), 'processes.user_id', '=', 'user.id') ->orderBy(...$orderBy) - ->get() - ->collect(); - - foreach ($processes as $key => $process) { - // filter the start events that can be used manually (no timer start events); - // TODO: startEvents is not a real property on Process. - // Move below to $process->getManualStartEvents(); - $process->startEvents = $process->events->filter(function ($event) { - $eventIsTimerStart = collect($event['eventDefinitions']) - ->filter(function ($eventDefinition) { - return $eventDefinition['$type'] == 'timerEventDefinition'; - })->count() > 0; - - // Filter out web entry start events and email start events - $eventIsWebEntry = false; - $eventIsEmailStart = false; - if (isset($event['config'])) { - $config = json_decode($event['config'], true); - if (isset($config['web_entry']) && $config['web_entry'] !== null) { - $eventIsWebEntry = true; - } elseif (isset($config['email_start']) && $config['email_start'] !== null) { - $eventIsEmailStart = true; - } - } + ->paginate($perPage); - return !$eventIsTimerStart && !$eventIsWebEntry && !$eventIsEmailStart; - })->values(); + foreach ($processes as $process) { + $process->setAppends(array_values(array_diff($process->getAppends(), ['projects']))); + $process->makeHidden('notification_settings'); - // Get the id bookmark related - $process->bookmark_id = Bookmark::getBookmarked($bookmark, $process->id, $user->id); - // Get the launchpad configuration - $process->launchpad = ProcessLaunchpad::getLaunchpad($launchpad, $process->id); + $process->bookmark_id = $bookmark + ? Bookmark::getBookmarked($bookmark, $process->id, $user->id) + : 0; + $process->launchpad = $launchpad + ? ProcessLaunchpad::getLaunchpad($launchpad, $process->id) + : null; $process->case_retention_tier_adjustment_notice = false; if ($user->is_administrator && config('app.case_retention_policy_enabled')) { $process->case_retention_tier_adjustment_notice = CaseRetentionTierService::adjustmentNoticeIsActive($process); } - - // Filter all processes that have event definitions (start events like message event, conditional event, signal event, timer event) - if ($request->has('without_event_definitions') && $request->input('without_event_definitions') == 'true') { - $startEvents = $process->events->filter(function ($event) { - return collect($event['eventDefinitions'])->isEmpty(); - }); - } - - // filter only valid executable processes - if (!$process->isValidForExecution()) { - $processes->startEvents = []; - } } return new ProcessCollection($processes); From a550247aafae6d7ed0bd45ce11818110e5e2687e Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 10 Sep 2026 12:44:11 -0400 Subject: [PATCH 3/4] feat: correct query parameters in ProcessMixin to ensure proper data retrieval --- resources/js/processes/components/ProcessMixin.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/js/processes/components/ProcessMixin.js b/resources/js/processes/components/ProcessMixin.js index 41e0ccad0c..1981a9a749 100644 --- a/resources/js/processes/components/ProcessMixin.js +++ b/resources/js/processes/components/ProcessMixin.js @@ -91,8 +91,7 @@ export default { this.orderBy }&order_direction=${ this.orderDirection - }&include=categories,category,user` - + "&with=events", + }&include=categories,category,user`, { cancelToken: new CancelToken((c) => { this.cancelToken = c; From fdd28def1b9a22c3286bc69bd94daf261226453b Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Fri, 11 Sep 2026 14:56:54 -0400 Subject: [PATCH 4/4] test: add tests for process listing, including pagination, sorting, and response structure validation --- tests/Feature/Api/ProcessTest.php | 329 ++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) diff --git a/tests/Feature/Api/ProcessTest.php b/tests/Feature/Api/ProcessTest.php index 82c66ef079..e0d331cdcf 100644 --- a/tests/Feature/Api/ProcessTest.php +++ b/tests/Feature/Api/ProcessTest.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use ProcessMaker\Models\Bookmark; use ProcessMaker\Models\Group; @@ -14,6 +15,8 @@ use ProcessMaker\Models\Permission; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessCategory; +use ProcessMaker\Models\ProcessLaunchpad; +use ProcessMaker\Models\ProcessNotificationSetting; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\User; use ProcessMaker\Providers\WorkflowServiceProvider as PM; @@ -629,6 +632,332 @@ public function testPagination() $response->assertJsonCount((2 + $initialRows) % 5, 'data'); } + public function testProcessListingResponseSupportsDesignerTables() + { + $manager = User::factory()->create(); + $process = Process::factory()->create([ + 'name' => 'Timer Process Listing Contract', + 'description' => 'Used by process row actions', + 'bpmn' => file_get_contents(__DIR__ . '/processes/ProcessStartTimerEvent.bpmn'), + 'pause_timer_start' => true, + 'properties' => ['manager_id' => [$manager->id]], + 'warnings' => [['message' => 'Test warning']], + ]); + + $response = $this->apiCall('GET', route('api.processes.index', [ + 'filter' => $process->name, + 'include' => 'categories,category,user', + ])); + + $response->assertOk(); + $row = collect($response->json('data'))->firstWhere('id', $process->id); + + $this->assertNotNull($row); + $this->assertSame($process->description, $row['description']); + $this->assertSame([$manager->id], $row['manager_id']); + $this->assertTrue($row['has_timer_start_events']); + $this->assertTrue((bool) $row['pause_timer_start']); + $this->assertArrayHasKey('warnings', $row); + $this->assertArrayHasKey('case_retention_tier_adjustment_notice', $row); + $this->assertArrayHasKey('user', $row); + $this->assertArrayHasKey('categories', $row); + $this->assertArrayHasKey('category', $row); + $this->assertArrayHasKey('notifications', $row); + $this->assertArrayHasKey('task_notifications', $row); + $this->assertArrayNotHasKey('events', $row); + $this->assertArrayNotHasKey('startEvents', $row); + $this->assertArrayNotHasKey('projects', $row); + $this->assertArrayNotHasKey('bpmn', $row); + $this->assertArrayNotHasKey('svg', $row); + } + + public function testProcessListingQueriesAreBoundedByPageSize() + { + $user = User::factory()->create(); + $category = ProcessCategory::factory()->create(); + $attributes = [ + 'user_id' => $user->id, + 'process_category_id' => $category->id, + ]; + Process::factory()->count(20)->create($attributes); + + $route = route('api.processes.index', [ + 'page' => 1, + 'per_page' => 15, + 'include' => 'categories,category,user', + ]); + + // Warm schema and application caches before comparing query counts. + $this->apiCall('GET', $route); + $initialQueries = $this->captureListingQueries($route); + + Process::factory()->count(100)->create($attributes); + $queriesWithMoreProcesses = $this->captureListingQueries($route); + + $this->assertCount(15, $this->apiCall('GET', $route)->json('data')); + $this->assertCount(count($initialQueries), $queriesWithMoreProcesses); + + $listingQuery = collect($queriesWithMoreProcesses)->first(function ($query) { + $sql = strtolower($query['query']); + + return str_contains($sql, 'from `processes`') && str_contains($sql, 'limit 15'); + }); + + $this->assertNotNull($listingQuery, 'The process listing query must apply the requested SQL limit.'); + $this->assertStringNotContainsString('`processes`.*', $listingQuery['query']); + $this->assertStringNotContainsString('`processes`.`bpmn`', $listingQuery['query']); + $this->assertStringNotContainsString('`processes`.`svg`', $listingQuery['query']); + + $projectQueries = collect($queriesWithMoreProcesses)->filter( + fn ($query) => str_contains($query['query'], 'from `project_assets`') + ); + $notificationQueries = collect($queriesWithMoreProcesses)->filter( + fn ($query) => str_contains($query['query'], 'from `process_notification_settings`') + ); + $versionQueries = collect($queriesWithMoreProcesses)->filter(function ($query) { + preg_match('/\bfrom\s+`?([^`\s]+)`?/i', $query['query'], $matches); + + return ($matches[1] ?? null) === 'process_versions'; + }); + + $this->assertCount(0, $projectQueries); + $this->assertCount(1, $notificationQueries); + $this->assertCount(0, $versionQueries); + } + + public function testProcessListingSortsByDesignerRelatedFields() + { + $firstUser = User::factory()->create(['username' => 'aaaa-process-owner']); + $lastUser = User::factory()->create(['username' => 'zzzz-process-owner']); + $firstCategory = ProcessCategory::factory()->create(['name' => 'AAAA Process Category']); + $lastCategory = ProcessCategory::factory()->create(['name' => 'ZZZZ Process Category']); + $lastProcess = Process::factory()->create([ + 'name' => 'Related Process Sort Last', + 'user_id' => $lastUser->id, + 'process_category_id' => $lastCategory->id, + ]); + $firstProcess = Process::factory()->create([ + 'name' => 'Related Process Sort First', + 'user_id' => $firstUser->id, + 'process_category_id' => $firstCategory->id, + ]); + + $parameters = ['filter' => 'Related Process Sort', 'order_direction' => 'asc']; + $response = $this->apiCall('GET', route('api.processes.index', [ + ...$parameters, + 'order_by' => 'user.username', + ])); + $response->assertJsonPath('data.0.id', $firstProcess->id); + + $response = $this->apiCall('GET', route('api.processes.index', [ + ...$parameters, + 'order_by' => 'category.name', + ])); + $response->assertJsonPath('data.0.id', $firstProcess->id); + $response->assertJsonFragment(['id' => $lastProcess->id]); + } + + public function testProcessListingPaginatesPmqlResults() + { + Process::factory()->count(3)->create(['name' => 'PMQL Pagination Match']); + Process::factory()->create(['name' => 'PMQL Pagination Nonmatch']); + + $response = $this->apiCall('GET', route('api.processes.index', [ + 'pmql' => 'name = "PMQL Pagination Match"', + 'per_page' => 2, + ])); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('meta.total', 3); + $response->assertJsonPath('meta.last_page', 2); + } + + public function testProcessListingLimitsIncludesAndEagerLoadsToCurrentPage() + { + $bpmn = trim(Process::getProcessTemplate('SingleTask.bpmn')); + $processes = collect(range(1, 5))->map(function ($number) use ($bpmn) { + $process = Process::factory()->create([ + 'name' => sprintf('Advanced Page Boundary %02d', $number), + 'bpmn' => $bpmn, + ]); + ProcessNotificationSetting::factory()->create([ + 'process_id' => $process->id, + 'notifiable_type' => 'requester', + 'notification_type' => 'started', + ]); + + return $process; + }); + $parameters = [ + 'filter' => 'Advanced Page Boundary', + 'order_by' => 'name', + 'order_direction' => 'asc', + 'page' => 2, + 'per_page' => 2, + 'include' => 'category,user', + ]; + $routeWithoutEvents = route('api.processes.index', $parameters); + $routeWithEvents = route('api.processes.index', [ + ...$parameters, + 'include' => 'category,user,events', + ]); + + $this->apiCall('GET', $routeWithoutEvents); + $this->apiCall('GET', $routeWithEvents); + $queriesWithoutEvents = $this->captureListingQueries($routeWithoutEvents); + $queriesWithEvents = $this->captureListingQueries($routeWithEvents); + $response = $this->apiCall('GET', $routeWithEvents); + $expectedIds = $processes->slice(2, 2)->pluck('id')->values()->all(); + + $response->assertOk(); + $this->assertSame($expectedIds, collect($response->json('data'))->pluck('id')->all()); + $this->assertCount(count($queriesWithoutEvents), $queriesWithEvents); + foreach ($response->json('data') as $row) { + $this->assertCount(1, $row['events']); + $this->assertArrayNotHasKey('bpmn', $row); + $this->assertArrayNotHasKey('svg', $row); + } + + $notificationQuery = collect($queriesWithEvents)->first( + fn ($query) => str_contains($query['query'], 'from `process_notification_settings`') + ); + + $this->assertNotNull($notificationQuery); + $this->assertSame( + 1, + preg_match('/`process_id` in \(([^)]+)\)/', $notificationQuery['query'], $matches) + ); + $eagerLoadedProcessIds = array_map('intval', preg_split('/,\s*/', $matches[1])); + $this->assertEqualsCanonicalizing( + $expectedIds, + $eagerLoadedProcessIds + ); + } + + public function testProcessListingSerializesBatchedNotificationSettings() + { + $process = Process::factory()->create(['name' => 'Advanced Notification Payload']); + ProcessNotificationSetting::factory()->create([ + 'process_id' => $process->id, + 'notifiable_type' => 'requester', + 'notification_type' => 'started', + ]); + ProcessNotificationSetting::factory()->create([ + 'process_id' => $process->id, + 'notifiable_type' => 'manager', + 'notification_type' => 'error', + ]); + ProcessNotificationSetting::factory()->create([ + 'process_id' => $process->id, + 'element_id' => 'AdvancedTask', + 'notifiable_type' => 'assignee', + 'notification_type' => 'assigned', + ]); + ProcessNotificationSetting::factory()->create([ + 'process_id' => $process->id, + 'element_id' => 'AdvancedTask', + 'notifiable_type' => 'manager', + 'notification_type' => 'due', + ]); + $route = route('api.processes.index', ['filter' => $process->name]); + + $queries = $this->captureListingQueries($route); + $response = $this->apiCall('GET', $route); + + $response->assertOk(); + $response->assertJsonPath('data.0.notifications.requester.started', true); + $response->assertJsonPath('data.0.notifications.requester.completed', false); + $response->assertJsonPath('data.0.notifications.manager.error', true); + $response->assertJsonPath('data.0.task_notifications.AdvancedTask.assignee.assigned', true); + $response->assertJsonPath('data.0.task_notifications.AdvancedTask.assignee.due', false); + $response->assertJsonPath('data.0.task_notifications.AdvancedTask.manager.due', true); + $this->assertArrayNotHasKey('notification_settings', $response->json('data.0')); + $this->assertCount(1, collect($queries)->filter( + fn ($query) => str_contains($query['query'], 'from `process_notification_settings`') + )); + } + + public function testProcessListingOnlyRunsOptionalLookupsWhenRequested() + { + $process = Process::factory()->create(['name' => 'Advanced Optional Lookups']); + $bookmark = Bookmark::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $this->user->id, + ]); + $launchpad = ProcessLaunchpad::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $this->user->id, + ]); + $defaultRoute = route('api.processes.index', ['filter' => $process->name]); + + $defaultQueries = collect($this->captureListingQueries($defaultRoute)); + $defaultResponse = $this->apiCall('GET', $defaultRoute); + + $defaultResponse->assertJsonPath('data.0.bookmark_id', 0); + $defaultResponse->assertJsonPath('data.0.launchpad', null); + $this->assertFalse($defaultQueries->contains( + fn ($query) => str_contains($query['query'], 'from `user_process_bookmarks`') + )); + $this->assertFalse($defaultQueries->contains( + fn ($query) => str_contains($query['query'], 'from `process_launchpad`') + )); + + $enabledRoute = route('api.processes.index', [ + 'filter' => $process->name, + 'bookmark' => true, + 'launchpad' => true, + ]); + $enabledQueries = collect($this->captureListingQueries($enabledRoute)); + $enabledResponse = $this->apiCall('GET', $enabledRoute); + + $enabledResponse->assertJsonPath('data.0.bookmark_id', $bookmark->id); + $enabledResponse->assertJsonPath('data.0.launchpad.id', $launchpad->id); + $this->assertCount(1, $enabledQueries->filter( + fn ($query) => str_contains($query['query'], 'from `user_process_bookmarks`') + )); + $this->assertCount(1, $enabledQueries->filter( + fn ($query) => str_contains($query['query'], 'from `process_launchpad`') + )); + } + + public function testProcessListingDoesNotEagerLoadRelationshipsForEmptyPage() + { + Process::factory()->count(3)->create(['name' => 'Advanced Empty Page']); + $route = route('api.processes.index', [ + 'filter' => 'Advanced Empty Page', + 'page' => 3, + 'per_page' => 2, + 'include' => 'category,user,events', + ]); + + $queries = collect($this->captureListingQueries($route)); + $response = $this->apiCall('GET', $route); + + $response->assertOk(); + $response->assertJsonCount(0, 'data'); + $response->assertJsonPath('meta.total', 3); + $response->assertJsonPath('meta.current_page', 3); + $response->assertJsonPath('meta.last_page', 2); + $this->assertFalse($queries->contains( + fn ($query) => str_contains($query['query'], 'from `process_notification_settings`') + )); + } + + private function captureListingQueries(string $route): array + { + $connection = DB::connection('processmaker'); + $connection->flushQueryLog(); + $connection->enableQueryLog(); + + $this->apiCall('GET', $route)->assertOk(); + $queries = $connection->getQueryLog(); + + $connection->disableQueryLog(); + + return $queries; + } + /** * Test the creation of processes */