Skip to content
2 changes: 1 addition & 1 deletion ProcessMaker/Console/Commands/EvaluateCaseRetention.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function handle()
$this->info('Evaluating and deleting cases past their retention period');

// Process all processes when retention policy is enabled
// Processes without retention_period will default to 1_year
// Processes without retention_period will default to one_year
Process::chunkById(100, function ($processes) {
foreach ($processes as $process) {
dispatch(new EvaluateProcessRetentionJob($process->id));
Expand Down
16 changes: 0 additions & 16 deletions ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,4 @@ private function getTaskDraftIds(array $tokenIds): array
->pluck('id')
->all();
}

private function dispatchSavedSearchRecount(): void
{
if (!config('savedsearch.count', false)) {
return;
}

$jobClass = 'ProcessMaker\\Package\\SavedSearch\\Jobs\\RecountAllSavedSearches';
if (!class_exists($jobClass)) {
return;
}

DB::afterCommit(static function () use ($jobClass): void {
$jobClass::dispatch(['request', 'task']);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,30 @@

trait DeletesCaseRecords
{
private function deleteCasesStarted(string $caseNumber): void
private function deleteCasesStarted(string | array $caseNumbers): void
{
CaseStarted::query()
->where('case_number', $caseNumber)
->delete();
if (is_array($caseNumbers) && $caseNumbers !== []) {
CaseStarted::query()
->whereIn('case_number', $caseNumbers)
->delete();
} else {
CaseStarted::query()
->where('case_number', $caseNumbers)
->delete();
}
}

private function deleteCasesParticipated(string $caseNumber): void
private function deleteCasesParticipated(string | array $caseNumbers): void
{
CaseParticipated::query()
->where('case_number', $caseNumber)
->delete();
if (is_array($caseNumbers)) {
CaseParticipated::query()
->whereIn('case_number', $caseNumbers)
->delete();
} else {
CaseParticipated::query()
->where('case_number', $caseNumbers)
->delete();
}
}

private function deleteCaseNumbers(array $requestIds): void
Expand Down Expand Up @@ -183,11 +195,18 @@ private function deleteRequestMedia(array $requestIds): void
->delete();
}

private function deleteComments(string $caseNumber, array $requestIds, array $tokenIds): void
private function deleteComments(string | array $caseNumbers, array $requestIds, array $tokenIds): void
{
Comment::query()
->where('case_number', $caseNumber)
->orWhere(function ($query) use ($requestIds, $tokenIds) {
if (is_array($caseNumbers) && $caseNumbers !== []) {
$query = Comment::query()
->whereIn('case_number', $caseNumbers);
} else {
$query = Comment::query()
->where('case_number', $caseNumbers);
}

if ($requestIds !== [] || $tokenIds !== []) {
$query->orWhere(function ($query) use ($requestIds, $tokenIds) {
$query->where('commentable_type', ProcessRequest::class)
->whereIn('commentable_id', $requestIds);

Expand All @@ -197,8 +216,10 @@ private function deleteComments(string $caseNumber, array $requestIds, array $to
->whereIn('commentable_id', $tokenIds);
});
}
})
->delete();
});
}

$query->delete();
}

private function deleteNotifications(array $requestIds): void
Expand All @@ -220,4 +241,20 @@ private function deleteNotifications(array $requestIds): void
->whereIn('data->type', $notificationTypes)
->delete();
}

private function dispatchSavedSearchRecount(): void
{
if (!config('savedsearch.count', false)) {
return;
}

$jobClass = 'ProcessMaker\\Package\\SavedSearch\\Jobs\\RecountAllSavedSearches';
if (!class_exists($jobClass)) {
return;
}

DB::afterCommit(static function () use ($jobClass): void {
$jobClass::dispatch(['request', 'task']);
});
}
}
127 changes: 104 additions & 23 deletions ProcessMaker/Jobs/EvaluateProcessRetentionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Http\Controllers\Api\Actions\Cases\DeletesCaseRecords;
use ProcessMaker\Models\CaseNumber;
use ProcessMaker\Models\CaseParticipated;
use ProcessMaker\Models\CaseStarted;
use ProcessMaker\Models\Process;
use ProcessMaker\Models\ProcessRequest;
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\TaskDraft;

class EvaluateProcessRetentionJob implements ShouldQueue
{
use Queueable;
use Queueable, DeletesCaseRecords;

/**
* Create a new job instance.
Expand All @@ -39,14 +44,14 @@ public function handle(): void
return;
}

// Default to 1_year if retention_period is not set
$retentionPeriod = $process->properties['retention_period'] ?? '1_year';
// Default to one_year if retention_period is not set
$retentionPeriod = $process->properties['retention_period'] ?? 'one_year';
$retentionMonths = match ($retentionPeriod) {
'6_months' => 6,
'1_year' => 12,
'3_years' => 36,
'5_years' => 60,
default => 12, // Default to 1_year
'six_months' => 6,
'one_year' => 12,
'three_years' => 36,
'five_years' => 60,
default => 12, // Default to one_year
};

// Default retention_updated_at to now if not set
Expand Down Expand Up @@ -78,28 +83,104 @@ public function handle(): void
// Use subquery to get process request IDs
$processRequestSubquery = ProcessRequest::where('process_id', $this->processId)->select('id');

// Collect all ProcessRequest IDs that will be deleted (to delete them after all chunks are processed)
$processRequestIdsToDelete = [];

CaseNumber::whereIn('process_request_id', $processRequestSubquery)
->where(function ($query) use ($retentionUpdatedAt, $oldCasesCutoff, $newCasesCutoff) {
// Cases created before retention_updated_at: delete if created before (retention_updated_at - retention_period)
$query->where(function ($q) use ($retentionUpdatedAt, $oldCasesCutoff) {
$q->where('created_at', '<', $retentionUpdatedAt)
->where('created_at', '<', $oldCasesCutoff);
})
// Cases created after retention_updated_at: delete if created before (now - retention_period)
->orWhere(function ($q) use ($retentionUpdatedAt, $newCasesCutoff) {
$q->where('created_at', '>=', $retentionUpdatedAt)
->where('created_at', '<', $newCasesCutoff);
});
})
->chunkById(100, function ($cases) {
$caseIds = $cases->pluck('id');
// Delete the cases
->where($this->buildRetentionQuery($retentionUpdatedAt, $oldCasesCutoff, $newCasesCutoff))
->chunkById(100, function ($cases) use (&$processRequestIdsToDelete) {
$caseIds = $cases->pluck('id')->all();
$processRequestIds = $cases->pluck('process_request_id')->unique()->all();

// Collect ProcessRequest IDs for deletion after all chunks are processed
$processRequestIdsToDelete = array_merge($processRequestIdsToDelete, $processRequestIds);

$processRequestTokenIds = ProcessRequestToken::whereIn('process_request_id', $processRequestIds)->pluck('id')->all();
$draftIds = $this->getTaskDraftIds($processRequestTokenIds);

// uses case_number to delete
$this->deleteCasesStarted($caseIds);
$this->deleteCasesParticipated($caseIds);
$this->deleteComments($caseIds, $processRequestIds, $processRequestTokenIds);

// Delete the CaseNumber records that were returned by the query (by their IDs)
CaseNumber::whereIn('id', $caseIds)->delete();

$this->deleteProcessRequestLocks($processRequestIds, $processRequestTokenIds);
$this->deleteInboxRuleLogs($processRequestTokenIds);
$this->deleteInboxRules($processRequestTokenIds);
$this->deleteProcessAbeRequestTokens($processRequestIds, $processRequestTokenIds);
$this->deleteScheduledTasks($processRequestIds, $processRequestTokenIds);
$this->deleteEllucianEthosSyncTasks($processRequestTokenIds);

$this->deleteTaskDraftMedia($draftIds);
$this->deleteTaskDrafts($processRequestTokenIds);

// TODO: Add logs to track the number of cases deleted
// Get deleted timestamp
// $deletedAt = Carbon::now();
// RetentionPolicyLog::record($process->id, $caseIds, $deletedAt);
});

// Delete ProcessRequests after all chunks are processed
// Only delete ProcessRequests that have no remaining cases
if (!empty($processRequestIdsToDelete)) {
$processRequestIdsToDelete = array_unique($processRequestIdsToDelete);

// Filter to only ProcessRequests that have no remaining CaseNumbers
$processRequestIdsWithNoCases = array_filter($processRequestIdsToDelete, function ($requestId) {
return !CaseNumber::where('process_request_id', $requestId)->exists();
});

if (!empty($processRequestIdsWithNoCases)) {
$this->deleteProcessRequests($processRequestIdsWithNoCases);

// Delete any remaining related records
$this->deleteRequestMedia($processRequestIdsWithNoCases);
$this->deleteNotifications($processRequestIdsWithNoCases);

$this->dispatchSavedSearchRecount();
}
}
}

/**
* Build a retention query closure that can be applied to any query builder.
*
* This method encapsulates the retention evaluation logic:
* - Cases created before retention_updated_at: delete if created before (retention_updated_at - retention_period)
* - Cases created after retention_updated_at: delete if created before (now - retention_period)
*
* @param Carbon $retentionUpdatedAt The date when the retention policy was updated
* @param Carbon $oldCasesCutoff The cutoff date for cases created before retention_updated_at
* @param Carbon $newCasesCutoff The cutoff date for cases created after retention_updated_at
* @return \Closure A closure that applies the retention query to a query builder
*/
private function buildRetentionQuery(Carbon $retentionUpdatedAt, Carbon $oldCasesCutoff, Carbon $newCasesCutoff): \Closure
{
return function ($query) use ($retentionUpdatedAt, $oldCasesCutoff, $newCasesCutoff) {
// Cases created before retention_updated_at: delete if created before (retention_updated_at - retention_period)
$query->where(function ($q) use ($retentionUpdatedAt, $oldCasesCutoff) {
$q->where('created_at', '<', $retentionUpdatedAt)
->where('created_at', '<', $oldCasesCutoff);
})
// Cases created after retention_updated_at: delete if created before (now - retention_period)
->orWhere(function ($q) use ($retentionUpdatedAt, $newCasesCutoff) {
$q->where('created_at', '>=', $retentionUpdatedAt)
->where('created_at', '<', $newCasesCutoff);
});
};
}

private function getTaskDraftIds(array $tokenIds): array
{
if ($tokenIds === []) {
return [];
}

return TaskDraft::query()
->whereIn('task_id', $tokenIds)
->pluck('id')
->all();
}
}
Loading