Skip to content

Commit c4c76f0

Browse files
committed
Merge remote-tracking branch 'origin/develop' into epic/FOUR-25679
2 parents 77c992f + 020fda6 commit c4c76f0

86 files changed

Lines changed: 4154 additions & 401 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace ProcessMaker\CaseRetention;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
7+
final class CaseRetentionLogCsvWriter
8+
{
9+
/**
10+
* Stream CSV rows to a writable stream (no column header row). UTF-8 BOM prepended.
11+
*
12+
* @param resource $stream
13+
*/
14+
public static function writeQueryToStream(Builder $query, $stream): void
15+
{
16+
fwrite($stream, "\xEF\xBB\xBF");
17+
18+
$query->clone()->chunkById(500, function ($rows) use ($stream) {
19+
foreach ($rows as $row) {
20+
$caseIds = $row->case_ids;
21+
if (is_array($caseIds)) {
22+
$caseIds = json_encode($caseIds);
23+
}
24+
25+
fputcsv($stream, [
26+
$row->id,
27+
$row->process_id,
28+
$caseIds,
29+
$row->deleted_count,
30+
$row->total_time_taken,
31+
self::csvDateColumn($row->deleted_at),
32+
self::csvDateColumn($row->created_at),
33+
]);
34+
}
35+
});
36+
}
37+
38+
public static function csvDateColumn(mixed $value): string
39+
{
40+
if ($value === null || $value === '') {
41+
return '';
42+
}
43+
if ($value instanceof \DateTimeInterface) {
44+
return $value->format('Y-m-d H:i:s');
45+
}
46+
47+
return (string) $value;
48+
}
49+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace ProcessMaker\CaseRetention;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
7+
final class CaseRetentionLogQueryFilter
8+
{
9+
public static function applyIfFilled(Builder $query, ?string $filter): void
10+
{
11+
if ($filter === null || trim($filter) === '') {
12+
return;
13+
}
14+
15+
self::apply($query, trim($filter));
16+
}
17+
18+
/**
19+
* Search log id, process_id, numeric columns, and JSON case_ids — not date columns.
20+
*/
21+
public static function apply(Builder $query, string $term): void
22+
{
23+
$like = '%' . $term . '%';
24+
$driver = $query->getConnection()->getDriverName();
25+
26+
$query->where(function ($q) use ($like, $driver) {
27+
$q->where('id', 'like', $like)
28+
->orWhere('process_id', 'like', $like)
29+
->orWhere('deleted_count', 'like', $like)
30+
->orWhere('total_time_taken', 'like', $like);
31+
32+
if ($driver === 'pgsql') {
33+
$q->orWhereRaw('case_ids::text ILIKE ?', [$like]);
34+
} else {
35+
$q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]);
36+
}
37+
});
38+
}
39+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
namespace ProcessMaker\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use ProcessMaker\Jobs\EvaluateProcessRetentionJob;
7+
use ProcessMaker\Models\Process;
8+
use ProcessMaker\Models\ProcessCategory;
9+
use ProcessMaker\Services\CaseRetentionTierService;
10+
11+
class EvaluateCaseRetention extends Command
12+
{
13+
/**
14+
* The name and signature of the console command.
15+
*
16+
* @var string
17+
*/
18+
protected $signature = 'cases:retention:evaluate';
19+
20+
/**
21+
* The console command description.
22+
*
23+
* @var string
24+
*/
25+
protected $description = 'Evaluate and delete cases past their retention period';
26+
27+
/**
28+
* Execute the console command.
29+
*/
30+
public function handle()
31+
{
32+
// Only run if case retention policy is enabled
33+
$enabled = config('app.case_retention_policy_enabled', false);
34+
if (!$enabled) {
35+
$this->info('Case retention policy is disabled');
36+
$this->error('Skipping case retention evaluation');
37+
38+
return;
39+
}
40+
41+
$this->info('Case retention policy is enabled');
42+
$this->info('Dispatching retention evaluation jobs for all processes');
43+
// Get the allowed periods for the current tier (support for downgrading to a lower tier)
44+
$tierAllowedPeriods = CaseRetentionTierService::allowedPeriodsForCurrentTier();
45+
46+
// Get system category IDs to exclude
47+
$systemCategoryIds = ProcessCategory::where('is_system', true)->pluck('id');
48+
49+
// Exclude processes that are templates or in system categories
50+
$jobCount = 0;
51+
$query = Process::where('is_template', '!=', 1);
52+
53+
// Exclude processes in system categories
54+
if ($systemCategoryIds->isNotEmpty()) {
55+
$query->where(function ($q) use ($systemCategoryIds) {
56+
$q->where(function ($subQuery) use ($systemCategoryIds) {
57+
$subQuery->whereNotIn('process_category_id', $systemCategoryIds)
58+
->orWhereNull('process_category_id');
59+
});
60+
})
61+
->whereDoesntHave('categories', function ($q) use ($systemCategoryIds) {
62+
// Exclude processes with any category assignment to system categories
63+
$q->whereIn('process_categories.id', $systemCategoryIds);
64+
});
65+
}
66+
67+
$query->chunkById(100, function ($processes) use (&$jobCount, $tierAllowedPeriods) {
68+
foreach ($processes as $process) {
69+
dispatch(new EvaluateProcessRetentionJob($process->id, $tierAllowedPeriods));
70+
$jobCount++;
71+
}
72+
});
73+
74+
$this->info("Dispatched {$jobCount} retention evaluation job(s) to the queue");
75+
$this->info('Jobs will be processed asynchronously by queue workers');
76+
}
77+
}

ProcessMaker/Console/Kernel.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ protected function schedule(Schedule $schedule)
9090
break;
9191
}
9292

93+
// evaluate cases retention policy
94+
$schedule->command('cases:retention:evaluate')
95+
->daily()
96+
->onOneServer()
97+
->withoutOverlapping()
98+
->runInBackground();
99+
93100
// 5 minutes is recommended in https://laravel.com/docs/12.x/horizon#metrics
94101
$schedule->command('horizon:snapshot')->everyFiveMinutes();
95102
}

ProcessMaker/Contracts/PermissionRepositoryInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,9 @@ public function getGroupPermissionsById(int $groupId): array;
3333
* Get nested group permissions (recursive)
3434
*/
3535
public function getNestedGroupPermissions(int $groupId): array;
36+
37+
/**
38+
* Get all users affected by permissions inherited from the given group subtree.
39+
*/
40+
public function getAffectedUserIdsForGroup(int $groupId): array;
3641
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace ProcessMaker\Http\Controllers\Admin;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Support\Facades\Http;
7+
use ProcessMaker\Http\Controllers\Controller;
8+
9+
class CasesRetentionController extends Controller
10+
{
11+
public function index(Request $request)
12+
{
13+
return view('admin.cases-retention.index');
14+
}
15+
}

ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,4 @@ private function getTaskDraftIds(array $tokenIds): array
9999
->pluck('id')
100100
->all();
101101
}
102-
103-
private function dispatchSavedSearchRecount(): void
104-
{
105-
if (!config('savedsearch.count', false)) {
106-
return;
107-
}
108-
109-
$jobClass = 'ProcessMaker\\Package\\SavedSearch\\Jobs\\RecountAllSavedSearches';
110-
if (!class_exists($jobClass)) {
111-
return;
112-
}
113-
114-
DB::afterCommit(static function () use ($jobClass): void {
115-
$jobClass::dispatch(['request', 'task']);
116-
});
117-
}
118102
}

ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,30 @@
2121

2222
trait DeletesCaseRecords
2323
{
24-
private function deleteCasesStarted(string $caseNumber): void
24+
private function deleteCasesStarted(string | array $caseNumbers): void
2525
{
26-
CaseStarted::query()
27-
->where('case_number', $caseNumber)
28-
->delete();
26+
if (is_array($caseNumbers) && $caseNumbers !== []) {
27+
CaseStarted::query()
28+
->whereIn('case_number', $caseNumbers)
29+
->delete();
30+
} else {
31+
CaseStarted::query()
32+
->where('case_number', $caseNumbers)
33+
->delete();
34+
}
2935
}
3036

31-
private function deleteCasesParticipated(string $caseNumber): void
37+
private function deleteCasesParticipated(string | array $caseNumbers): void
3238
{
33-
CaseParticipated::query()
34-
->where('case_number', $caseNumber)
35-
->delete();
39+
if (is_array($caseNumbers)) {
40+
CaseParticipated::query()
41+
->whereIn('case_number', $caseNumbers)
42+
->delete();
43+
} else {
44+
CaseParticipated::query()
45+
->where('case_number', $caseNumbers)
46+
->delete();
47+
}
3648
}
3749

3850
private function deleteCaseNumbers(array $requestIds): void
@@ -183,11 +195,18 @@ private function deleteRequestMedia(array $requestIds): void
183195
->delete();
184196
}
185197

186-
private function deleteComments(string $caseNumber, array $requestIds, array $tokenIds): void
198+
private function deleteComments(string | array $caseNumbers, array $requestIds, array $tokenIds): void
187199
{
188-
Comment::query()
189-
->where('case_number', $caseNumber)
190-
->orWhere(function ($query) use ($requestIds, $tokenIds) {
200+
if (is_array($caseNumbers) && $caseNumbers !== []) {
201+
$query = Comment::query()
202+
->whereIn('case_number', $caseNumbers);
203+
} else {
204+
$query = Comment::query()
205+
->where('case_number', $caseNumbers);
206+
}
207+
208+
if ($requestIds !== [] || $tokenIds !== []) {
209+
$query->orWhere(function ($query) use ($requestIds, $tokenIds) {
191210
$query->where('commentable_type', ProcessRequest::class)
192211
->whereIn('commentable_id', $requestIds);
193212

@@ -197,8 +216,10 @@ private function deleteComments(string $caseNumber, array $requestIds, array $to
197216
->whereIn('commentable_id', $tokenIds);
198217
});
199218
}
200-
})
201-
->delete();
219+
});
220+
}
221+
222+
$query->delete();
202223
}
203224

204225
private function deleteNotifications(array $requestIds): void
@@ -220,4 +241,20 @@ private function deleteNotifications(array $requestIds): void
220241
->whereIn('data->type', $notificationTypes)
221242
->delete();
222243
}
244+
245+
private function dispatchSavedSearchRecount(): void
246+
{
247+
if (!config('savedsearch.count', false)) {
248+
return;
249+
}
250+
251+
$jobClass = 'ProcessMaker\\Package\\SavedSearch\\Jobs\\RecountAllSavedSearches';
252+
if (!class_exists($jobClass)) {
253+
return;
254+
}
255+
256+
DB::afterCommit(static function () use ($jobClass): void {
257+
$jobClass::dispatch(['request', 'task']);
258+
});
259+
}
223260
}

0 commit comments

Comments
 (0)