From af74433e79328fd81ee4288533a1ef1f0396de07 Mon Sep 17 00:00:00 2001
From: sanja <52755494+sanjacornelius@users.noreply.github.com>
Date: Tue, 24 Mar 2026 15:53:38 -0700
Subject: [PATCH 1/3] Add cases retention logs API endpoint
Introduce CasesRetentionController with a logs() action to expose case retention policy logs. The endpoint supports text filtering, validated ordering (whitelisted columns), order direction, and paginated results (per_page default 10); when no valid order_by is provided it falls back to created_at descending. The controller uses DB::raw to transform dotted column notation into JSON extraction for ordering and returns results as an ApiCollection. Also registers the GET api/1.0/cases-retention/logs route under the existing authenticated API middleware group.
---
.../Api/CasesRetentionController.php | 52 +++++++++++++++++++
routes/api.php | 4 ++
2 files changed, 56 insertions(+)
create mode 100644 ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
diff --git a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
new file mode 100644
index 0000000000..242e71ebc1
--- /dev/null
+++ b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
@@ -0,0 +1,52 @@
+input('filter')) {
+ $filter = '%' . mb_strtolower($filter) . '%';
+ $query->where('process_id', 'like', $filter);
+ }
+
+ $orderBy = $request->input('order_by');
+ if ($orderBy && in_array($orderBy, self::LOG_SORT_COLUMNS, true)) {
+ $orderBy = DB::raw(preg_replace('/\.(.+)/', "->>'\$.$1'", $orderBy, 1));
+
+ $orderDirection = strtolower((string) $request->input('order_direction', 'asc'));
+ if (!in_array($orderDirection, ['asc', 'desc'], true)) {
+ $orderDirection = 'asc';
+ }
+
+ $query->orderBy($orderBy, $orderDirection);
+ } else {
+ $query->orderByDesc('created_at');
+ }
+
+ $response = $query->paginate($request->input('per_page', 10));
+
+ return new ApiCollection($response);
+ }
+}
diff --git a/routes/api.php b/routes/api.php
index 99eb8b31d7..8617b3de7e 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -4,6 +4,7 @@
use ProcessMaker\Http\Controllers\Admin\TenantQueueController;
use ProcessMaker\Http\Controllers\Api\BookmarkController;
use ProcessMaker\Http\Controllers\Api\CaseController;
+use ProcessMaker\Http\Controllers\Api\CasesRetentionController;
use ProcessMaker\Http\Controllers\Api\ChangePasswordController;
use ProcessMaker\Http\Controllers\Api\CommentController;
use ProcessMaker\Http\Controllers\Api\CssOverrideController;
@@ -450,5 +451,8 @@
// Slack Connector Validation
Route::post('connector-slack/validate-token', [ProcessMaker\Packages\Connectors\Slack\Controllers\SlackController::class, 'validateToken'])->name('connector-slack.validate-token');
+
+ // Cases Retention
+ Route::get('cases-retention/logs', [CasesRetentionController::class, 'logs'])->name('cases-retention.logs');
});
Route::post('devlink/bundle-updated/{bundle}/{token}', [DevLinkController::class, 'bundleUpdated'])->name('devlink.bundle-updated');
From 2289a3a7920f7027648c695c109c81ae189be303 Mon Sep 17 00:00:00 2001
From: sanja <52755494+sanjacornelius@users.noreply.github.com>
Date: Tue, 24 Mar 2026 15:54:23 -0700
Subject: [PATCH 2/3] Cast case_ids as array and add CaseIdsTableCell
Treat case_ids as a native array across backend and frontend. Add a $casts entry on CaseRetentionPolicyLog and stop json-encoding case_ids in EvaluateProcessRetentionJob and the factory; update the unit test to assert the array value. On the UI side, introduce a dedicated CaseIdsTableCell Vue component (with preview + popover for overflow) and wire it into CasesRetentionLogs, replacing fake data with an API fetch and adjusting sorting/preview behavior.
---
.../Jobs/EvaluateProcessRetentionJob.php | 2 +-
.../Models/CaseRetentionPolicyLog.php | 4 +
.../Models/CaseRetentionPolicyLogFactory.php | 2 +-
.../components/CaseIdsTableCell.vue | 172 ++++++++++++++++++
.../components/CasesRetentionLogs.vue | 100 +++-------
.../Models/CaseRetentionPolicyLogTest.php | 2 +-
6 files changed, 208 insertions(+), 74 deletions(-)
create mode 100644 resources/js/admin/cases-retention/components/CaseIdsTableCell.vue
diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php
index a04cba2800..e5b14a267d 100644
--- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php
+++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php
@@ -184,7 +184,7 @@ public function handle(): void
CaseRetentionPolicyLog::create([
'process_id' => $this->processId,
- 'case_ids' => json_encode($caseIds),
+ 'case_ids' => $caseIds,
'deleted_count' => $chunkSize,
'total_time_taken' => $chunkTimeMs,
'deleted_at' => Carbon::now(),
diff --git a/ProcessMaker/Models/CaseRetentionPolicyLog.php b/ProcessMaker/Models/CaseRetentionPolicyLog.php
index 35da191f1e..a2d16c7364 100644
--- a/ProcessMaker/Models/CaseRetentionPolicyLog.php
+++ b/ProcessMaker/Models/CaseRetentionPolicyLog.php
@@ -25,4 +25,8 @@ class CaseRetentionPolicyLog extends ProcessMakerModel
'total_time_taken',
'deleted_at',
];
+
+ protected $casts = [
+ 'case_ids' => 'array',
+ ];
}
diff --git a/database/factories/ProcessMaker/Models/CaseRetentionPolicyLogFactory.php b/database/factories/ProcessMaker/Models/CaseRetentionPolicyLogFactory.php
index e38c9a3fb8..9a82e8d17d 100644
--- a/database/factories/ProcessMaker/Models/CaseRetentionPolicyLogFactory.php
+++ b/database/factories/ProcessMaker/Models/CaseRetentionPolicyLogFactory.php
@@ -22,7 +22,7 @@ public function definition()
'process_id' => function () {
return Process::factory()->create()->id;
},
- 'case_ids' => json_encode(CaseNumber::factory()->count($this->faker->numberBetween(1, 1000))->create()->pluck('id')->toArray()),
+ 'case_ids' => CaseNumber::factory()->count($this->faker->numberBetween(1, 1000))->create()->pluck('id')->toArray(),
'deleted_count' => $this->faker->numberBetween(1, 1000),
'total_time_taken' => $this->faker->numberBetween(1, 1000000),
'deleted_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
diff --git a/resources/js/admin/cases-retention/components/CaseIdsTableCell.vue b/resources/js/admin/cases-retention/components/CaseIdsTableCell.vue
new file mode 100644
index 0000000000..8710bc181e
--- /dev/null
+++ b/resources/js/admin/cases-retention/components/CaseIdsTableCell.vue
@@ -0,0 +1,172 @@
+
+
+
+ —
+
+
+ {{ ids.join(", ") }}
+
+
+
+
+
+ {{ $t("Case IDs") }}
+ ({{ ids.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue b/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue
index 933e99eef1..ea6f5ff671 100644
--- a/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue
+++ b/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue
@@ -34,7 +34,11 @@
slot="case_ids"
slot-scope="props"
>
- {{ props.rowData.case_ids.join(', ') }}
+
this.$t("Case IDs"),
name: "__slot:case_ids",
- sortField: "case_ids",
width: "16.66%",
},
{
@@ -189,32 +147,32 @@ export default {
width: "16.66%",
},
],
+ caseIdsPreviewLimit: 5,
};
},
watch: {
filter() {
- this.page = 1;
+ // this.page = 1;
this.fetch();
},
+
},
methods: {
fetch() {
- // TODO: replace with API call when retention_policy_logs table and endpoint exist
- const total = FAKE_RETENTION_LOGS.length;
- this.data = {
- data: FAKE_RETENTION_LOGS,
- meta: {
- total,
- per_page: 15,
- current_page: 1,
- last_page: 1,
- from: 1,
- to: total,
- total_pages: 1,
- count: total,
+ ProcessMaker.apiClient.get('cases-retention/logs', {
+ params: {
+ filter: this.filter,
+ order_by: this.orderBy,
+ order_direction: this.orderDirection,
+ page: this.page,
+ per_page: this.perPage,
},
- };
- this.apiDataLoading = false;
+ }).then(response => {
+ this.data = this.transform(response.data);
+ this.apiDataLoading = false;
+ }).catch(error => {
+ this.apiDataLoading = false;
+ });
},
reload() {
this.fetch();
diff --git a/tests/unit/ProcessMaker/Models/CaseRetentionPolicyLogTest.php b/tests/unit/ProcessMaker/Models/CaseRetentionPolicyLogTest.php
index 285340552a..52c388feb2 100644
--- a/tests/unit/ProcessMaker/Models/CaseRetentionPolicyLogTest.php
+++ b/tests/unit/ProcessMaker/Models/CaseRetentionPolicyLogTest.php
@@ -57,7 +57,7 @@ public function testJobAddsLogRecordWhenCasesAreDeleted(): void
$this->assertIsNumeric($log->total_time_taken);
$this->assertNotNull($log->deleted_at);
- $loggedCaseIds = json_decode($log->case_ids, true);
+ $loggedCaseIds = $log->case_ids;
$this->assertIsArray($loggedCaseIds);
$this->assertContains((int) $caseOld->id, array_map('intval', $loggedCaseIds));
From 5a6c16faa1e12c50fc25e9b8b9f3d54f5f566580 Mon Sep 17 00:00:00 2001
From: sanja <52755494+sanjacornelius@users.noreply.github.com>
Date: Tue, 24 Mar 2026 16:02:28 -0700
Subject: [PATCH 3/3] Improve logs filtering and remove unused imports
Introduce applyLogsFilter to perform flexible searching across id, process_id, numeric columns (deleted_count, total_time_taken) and JSON case_ids, using driver-specific casting (ILIKE for pgsql, CAST(... AS CHAR) otherwise). Use request->filled('filter') and trim empty terms to avoid spurious queries. Replace the previous lowercase process_id-only filter with this broader implementation and remove unused imports (Response, DB, Log).
---
.../Api/CasesRetentionController.php | 34 ++++++++++++++++---
1 file changed, 29 insertions(+), 5 deletions(-)
diff --git a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
index 242e71ebc1..218b7e0439 100644
--- a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
+++ b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
@@ -3,9 +3,7 @@
namespace ProcessMaker\Http\Controllers\Api;
use Illuminate\Http\Request;
-use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Log;
use ProcessMaker\Http\Controllers\Controller;
use ProcessMaker\Http\Resources\ApiCollection;
use ProcessMaker\Models\CaseRetentionPolicyLog;
@@ -22,13 +20,39 @@ class CasesRetentionController extends Controller
'created_at',
];
+ /**
+ * Search log id, process_id, numeric columns, and JSON case_ids — not date columns.
+ */
+ private function applyLogsFilter($query, string $term): void
+ {
+ $term = trim($term);
+ if ($term === '') {
+ return;
+ }
+
+ $like = '%' . $term . '%';
+ $driver = $query->getConnection()->getDriverName();
+
+ $query->where(function ($q) use ($like, $driver) {
+ $q->where('id', 'like', $like)
+ ->orWhere('process_id', 'like', $like)
+ ->orWhere('deleted_count', 'like', $like)
+ ->orWhere('total_time_taken', 'like', $like);
+
+ if ($driver === 'pgsql') {
+ $q->orWhereRaw('case_ids::text ILIKE ?', [$like]);
+ } else {
+ $q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]);
+ }
+ });
+ }
+
public function logs(Request $request): ApiCollection
{
$query = CaseRetentionPolicyLog::query();
- if ($filter = $request->input('filter')) {
- $filter = '%' . mb_strtolower($filter) . '%';
- $query->where('process_id', 'like', $filter);
+ if ($request->filled('filter')) {
+ $this->applyLogsFilter($query, (string) $request->input('filter'));
}
$orderBy = $request->input('order_by');