From 13baad2716593e8555b7830462ec2b2d1a705422 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:04:54 -0400 Subject: [PATCH 1/8] Delete cases_started and cases_participated --- ProcessMaker/Jobs/EvaluateProcessRetentionJob.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index f5d2415993..cd533260bf 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -7,6 +7,8 @@ use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; use ProcessMaker\Models\CaseNumber; +use ProcessMaker\Models\CaseParticipated; +use ProcessMaker\Models\CaseStarted; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; @@ -93,6 +95,10 @@ public function handle(): void }) ->chunkById(100, function ($cases) { $caseIds = $cases->pluck('id'); + // Delete the cases_started associated with the case_number + CaseStarted::whereIn('case_number', $caseIds)->delete(); + // Delete the cases_participated associated with the case_number + CaseParticipated::whereIn('case_number', $caseIds)->delete(); // Delete the cases CaseNumber::whereIn('id', $caseIds)->delete(); From af66e754153fa7185fabb6a5fbd51e1db5e19a64 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:05:53 -0400 Subject: [PATCH 2/8] Update tests to assert deletion of cases_started and cases_participated --- .../Jobs/EvaluateProcessRetentionJobTest.php | 97 +++++++++++++++++-- 1 file changed, 87 insertions(+), 10 deletions(-) diff --git a/tests/Jobs/EvaluateProcessRetentionJobTest.php b/tests/Jobs/EvaluateProcessRetentionJobTest.php index e8c1706de8..d4ab9a199b 100644 --- a/tests/Jobs/EvaluateProcessRetentionJobTest.php +++ b/tests/Jobs/EvaluateProcessRetentionJobTest.php @@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Config; use ProcessMaker\Jobs\EvaluateProcessRetentionJob; use ProcessMaker\Models\CaseNumber; +use ProcessMaker\Models\CaseParticipated; +use ProcessMaker\Models\CaseStarted; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; use Tests\TestCase; @@ -60,6 +62,12 @@ public function testItDeletesCasesThatExceedRetentionPeriod() 'created_at' => $oldCaseCreatedAt, 'process_request_id' => $processRequest->id, ]); + $casesStartedOld = CaseStarted::factory()->create([ + 'case_number' => $caseOld->id, + ]); + $casesParticipatedOld = CaseParticipated::factory()->create([ + 'case_number' => $caseOld->id, + ]); $this->assertEquals($processRequest->id, $caseOld->process_request_id); $this->assertEquals($oldCaseCreatedAt, $caseOld->created_at->toIso8601String()); @@ -67,7 +75,9 @@ public function testItDeletesCasesThatExceedRetentionPeriod() EvaluateProcessRetentionJob::dispatchSync($process->id); // Check that the case old has been deleted - $this->assertNull(CaseNumber::find($caseOld->id)); + $this->assertNull(CaseNumber::find($caseOld->id), 'The case number should be deleted'); + $this->assertNull(CaseStarted::find($casesStartedOld->id), 'The cases_started should be deleted'); + $this->assertNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should be deleted'); } public function testItDoesNotDeleteCasesThatAreWithinRetentionPeriod() @@ -98,6 +108,15 @@ public function testItDoesNotDeleteCasesThatAreWithinRetentionPeriod() 'created_at' => $caseCreatedAt, 'process_request_id' => $processRequest->id, ]); + // Create a cases_started for the case + $casesStarted = CaseStarted::factory()->create([ + 'case_number' => $case->id, + ]); + // Create a cases_participated for the case + $casesParticipated = CaseParticipated::factory()->create([ + 'case_number' => $case->id, + ]); + // Assert the case, cases_started, and cases_participated are created $this->assertEquals($processRequest->id, $case->process_request_id); $this->assertEquals($caseCreatedAt, $case->created_at->toIso8601String()); @@ -105,7 +124,9 @@ public function testItDoesNotDeleteCasesThatAreWithinRetentionPeriod() EvaluateProcessRetentionJob::dispatchSync($process->id); // Check that the case has not been deleted - $this->assertNotNull(CaseNumber::find($case->id)); + $this->assertNotNull(CaseNumber::find($case->id), 'The case should not be deleted'); + $this->assertNotNull(CaseStarted::find($casesStarted->id), 'The cases_started should not be deleted'); + $this->assertNotNull(CaseParticipated::find($casesParticipated->id), 'The cases_participated should not be deleted'); } public function testItHandlesMultipleCasesInBatches() @@ -132,12 +153,20 @@ public function testItHandlesMultipleCasesInBatches() // These cases are created 13 months ago // Cutoff = now - 12 months = 12 months ago // 13 months ago < 12 months ago, so these should be deleted - $cases = CaseNumber::factory()->count(1200)->create([ - 'process_request_id' => $processRequest->id, - 'created_at' => Carbon::now()->subMonths(13)->toIso8601String(), - ]); + $cases = CaseNumber::factory()->count(1200)->sequence(function () use ($processRequest) { + return ['process_request_id' => $processRequest->id, 'created_at' => Carbon::now()->subMonths(13)->toIso8601String()]; + })->create(); + $cases->each(function ($case) { + // Create one CaseStarted per CaseNumber + CaseStarted::factory()->create([ + 'case_number' => $case->id, + ]); + // Create one CaseParticipated per CaseNumber + CaseParticipated::factory()->create([ + 'case_number' => $case->id, + ]); + }); $this->assertEquals($processRequest->id, $cases->first()->process_request_id); - $this->assertEquals(Carbon::now()->subMonths(13)->toIso8601String(), $cases->first()->created_at->toIso8601String()); // Dispatch the job to evaluate the retention period EvaluateProcessRetentionJob::dispatchSync($process->id); @@ -146,6 +175,10 @@ public function testItHandlesMultipleCasesInBatches() // There should be 1 case left (the auto-created case from ProcessRequestObserver) // because it was created recently and is within the retention period $this->assertDatabaseCount('case_numbers', 1); + // Assert all case_started are deleted + $this->assertDatabaseCount('cases_started', 0); + // Assert all case_participated are deleted + $this->assertDatabaseCount('cases_participated', 0); } public function testItHandlesRetentionPolicyUpdate() @@ -220,6 +253,12 @@ public function testItDeletesOldCasesAfterRetentionPolicyUpdate() ]); $oldCase->created_at = $oldCaseDate; $oldCase->save(); + $casesStartedOld = CaseStarted::factory()->create([ + 'case_number' => $oldCase->id, + ]); + $casesParticipatedOld = CaseParticipated::factory()->create([ + 'case_number' => $oldCase->id, + ]); // Create a case 7 months ago (before retention_updated_at) that should NOT be deleted // Old cases cutoff = 6 months ago - 1 year = 18 months ago @@ -230,6 +269,12 @@ public function testItDeletesOldCasesAfterRetentionPolicyUpdate() ]); $oldCaseNotDeleted->created_at = $oldCaseNotDeletedDate; $oldCaseNotDeleted->save(); + $casesStartedNotDeleted = CaseStarted::factory()->create([ + 'case_number' => $oldCaseNotDeleted->id, + ]); + $casesParticipatedNotDeleted = CaseParticipated::factory()->create([ + 'case_number' => $oldCaseNotDeleted->id, + ]); // Dispatch the job EvaluateProcessRetentionJob::dispatchSync($process->id); @@ -238,8 +283,16 @@ public function testItDeletesOldCasesAfterRetentionPolicyUpdate() // The 7-month-old case should NOT be deleted (newer than 18 months cutoff) // Plus the auto-created case = 2 total $this->assertNull(CaseNumber::find($oldCase->id), 'The 20-month-old case should be deleted'); + $this->assertNull(CaseStarted::find($casesStartedOld->id), 'The cases_started should be deleted'); + $this->assertNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should be deleted'); + $this->assertNotNull(CaseNumber::find($oldCaseNotDeleted->id), 'The 7-month-old case should NOT be deleted'); + $this->assertNotNull(CaseStarted::find($casesStartedNotDeleted->id), 'The cases_started should NOT be deleted'); + $this->assertNotNull(CaseParticipated::find($casesParticipatedNotDeleted->id), 'The cases_participated should NOT be deleted'); + $this->assertDatabaseCount('case_numbers', 2); + $this->assertDatabaseCount('cases_started', 1); + $this->assertDatabaseCount('cases_participated', 1); } public function testItDoesNotRunWhenRetentionPolicyIsDisabled() @@ -271,14 +324,22 @@ public function testItDoesNotRunWhenRetentionPolicyIsDisabled() ]); $oldCase->created_at = $oldCaseDate; $oldCase->save(); - + $casesStartedOld = CaseStarted::factory()->create([ + 'case_number' => $oldCase->id, + ]); + $casesParticipatedOld = CaseParticipated::factory()->create([ + 'case_number' => $oldCase->id, + ]); // Dispatch the job EvaluateProcessRetentionJob::dispatchSync($process->id); // The case should NOT be deleted because retention policy is disabled // Plus the auto-created case = 2 total $this->assertNotNull(CaseNumber::find($oldCase->id), 'The case should NOT be deleted when retention policy is disabled'); + $this->assertNotNull(CaseStarted::find($casesStartedOld->id), 'The cases_started should NOT be deleted'); + $this->assertNotNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should NOT be deleted'); $this->assertDatabaseCount('case_numbers', 2); + $this->assertDatabaseCount('cases_started', 1); // Re-enable for other tests Config::set('app.case_retention_policy_enabled', true); @@ -308,7 +369,12 @@ public function testItDefaultsToOneYearForProcessesWithoutRetentionPeriod() ]); $oldCase->created_at = $oldCaseDate; $oldCase->save(); - + $casesStartedOld = CaseStarted::factory()->create([ + 'case_number' => $oldCase->id, + ]); + $casesParticipatedOld = CaseParticipated::factory()->create([ + 'case_number' => $oldCase->id, + ]); // Create a case created 5 months ago (within default 1 year retention) // 5 months ago is NOT < (now - 1 year), so it should NOT be deleted $newCaseDate = Carbon::now()->subMonths(5); @@ -317,7 +383,12 @@ public function testItDefaultsToOneYearForProcessesWithoutRetentionPeriod() ]); $newCase->created_at = $newCaseDate; $newCase->save(); - + $casesStartedNew = CaseStarted::factory()->create([ + 'case_number' => $newCase->id, + ]); + $casesParticipatedNew = CaseParticipated::factory()->create([ + 'case_number' => $newCase->id, + ]); // Dispatch the job EvaluateProcessRetentionJob::dispatchSync($process->id); @@ -325,7 +396,13 @@ public function testItDefaultsToOneYearForProcessesWithoutRetentionPeriod() // The 5-month-old case should NOT be deleted (within 1 year default) // Plus the auto-created case = 2 total $this->assertNull(CaseNumber::find($oldCase->id), 'The 13-month-old case should be deleted with default 1 year retention'); + $this->assertNull(CaseStarted::find($casesStartedOld->id), 'The cases_started should be deleted'); + $this->assertNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should be deleted'); $this->assertNotNull(CaseNumber::find($newCase->id), 'The 5-month-old case should NOT be deleted with default 1 year retention'); + $this->assertNotNull(CaseStarted::find($casesStartedNew->id), 'The cases_started should NOT be deleted'); + $this->assertNotNull(CaseParticipated::find($casesParticipatedNew->id), 'The cases_participated should NOT be deleted'); $this->assertDatabaseCount('case_numbers', 2); + $this->assertDatabaseCount('cases_started', 1); + $this->assertDatabaseCount('cases_participated', 1); } } From 4a0c48c6d00f35042734c805f71fef568dec66cf Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:45:16 -0800 Subject: [PATCH 3/8] Update tests to ensure ALL assets are deleted during retention job execution --- .../Jobs/EvaluateProcessRetentionJobTest.php | 123 +++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/Jobs/EvaluateProcessRetentionJobTest.php b/tests/Jobs/EvaluateProcessRetentionJobTest.php index d4ab9a199b..98f157122d 100644 --- a/tests/Jobs/EvaluateProcessRetentionJobTest.php +++ b/tests/Jobs/EvaluateProcessRetentionJobTest.php @@ -9,8 +9,12 @@ use ProcessMaker\Models\CaseNumber; use ProcessMaker\Models\CaseParticipated; use ProcessMaker\Models\CaseStarted; +use ProcessMaker\Models\Comment; +use ProcessMaker\Models\Media; +use ProcessMaker\Models\Notification; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestToken; use Tests\TestCase; class EvaluateProcessRetentionJobTest extends TestCase @@ -54,6 +58,10 @@ public function testItDeletesCasesThatExceedRetentionPeriod() $processRequest->refresh(); $this->assertEquals($process->id, $processRequest->process_id); + // Delete the auto-created CaseNumber from ProcessRequestObserver + // so we can test ProcessRequest deletion when all cases are removed + CaseNumber::where('process_request_id', $processRequest->id)->delete(); + // Create a case number created 13 months ago // Cutoff = now - 12 months = 12 months ago // 13 months ago < 12 months ago, so it should be deleted @@ -78,6 +86,8 @@ public function testItDeletesCasesThatExceedRetentionPeriod() $this->assertNull(CaseNumber::find($caseOld->id), 'The case number should be deleted'); $this->assertNull(CaseStarted::find($casesStartedOld->id), 'The cases_started should be deleted'); $this->assertNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should be deleted'); + // Check that the ProcessRequest has been deleted + $this->assertNull(ProcessRequest::find($processRequest->id), 'The process request should be deleted'); } public function testItDoesNotDeleteCasesThatAreWithinRetentionPeriod() @@ -127,6 +137,8 @@ public function testItDoesNotDeleteCasesThatAreWithinRetentionPeriod() $this->assertNotNull(CaseNumber::find($case->id), 'The case should not be deleted'); $this->assertNotNull(CaseStarted::find($casesStarted->id), 'The cases_started should not be deleted'); $this->assertNotNull(CaseParticipated::find($casesParticipated->id), 'The cases_participated should not be deleted'); + // Check that the ProcessRequest has not been deleted (cases still exist) + $this->assertNotNull(ProcessRequest::find($processRequest->id), 'The process request should not be deleted when cases still exist'); } public function testItHandlesMultipleCasesInBatches() @@ -149,6 +161,10 @@ public function testItHandlesMultipleCasesInBatches() $processRequest->refresh(); $this->assertEquals($process->id, $processRequest->process_id); + // Delete the auto-created CaseNumber from ProcessRequestObserver + // so we can test ProcessRequest deletion when all cases are removed + CaseNumber::where('process_request_id', $processRequest->id)->delete(); + // Create 1200 cases (to test chunking/batch deletion) // These cases are created 13 months ago // Cutoff = now - 12 months = 12 months ago @@ -172,13 +188,13 @@ public function testItHandlesMultipleCasesInBatches() EvaluateProcessRetentionJob::dispatchSync($process->id); // Assert all old cases are deleted - // There should be 1 case left (the auto-created case from ProcessRequestObserver) - // because it was created recently and is within the retention period - $this->assertDatabaseCount('case_numbers', 1); + $this->assertDatabaseCount('case_numbers', 0); // Assert all case_started are deleted $this->assertDatabaseCount('cases_started', 0); // Assert all case_participated are deleted $this->assertDatabaseCount('cases_participated', 0); + // Assert the ProcessRequest has been deleted (all its cases were deleted) + $this->assertNull(ProcessRequest::find($processRequest->id), 'The process request should be deleted'); } public function testItHandlesRetentionPolicyUpdate() @@ -223,6 +239,8 @@ public function testItHandlesRetentionPolicyUpdate() $this->assertNotNull(CaseNumber::find($oldCase->id)); $this->assertNotNull(CaseNumber::find($newCase->id)); $this->assertDatabaseCount('case_numbers', 3); + // ProcessRequest should not be deleted (cases still exist) + $this->assertNotNull(ProcessRequest::find($processRequest->id), 'The process request should not be deleted when cases still exist'); } public function testItDeletesOldCasesAfterRetentionPolicyUpdate() @@ -293,6 +311,8 @@ public function testItDeletesOldCasesAfterRetentionPolicyUpdate() $this->assertDatabaseCount('case_numbers', 2); $this->assertDatabaseCount('cases_started', 1); $this->assertDatabaseCount('cases_participated', 1); + // ProcessRequest should not be deleted (some cases still exist) + $this->assertNotNull(ProcessRequest::find($processRequest->id), 'The process request should not be deleted when some cases still exist'); } public function testItDoesNotRunWhenRetentionPolicyIsDisabled() @@ -340,6 +360,8 @@ public function testItDoesNotRunWhenRetentionPolicyIsDisabled() $this->assertNotNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should NOT be deleted'); $this->assertDatabaseCount('case_numbers', 2); $this->assertDatabaseCount('cases_started', 1); + // ProcessRequest should not be deleted (retention policy is disabled) + $this->assertNotNull(ProcessRequest::find($processRequest->id), 'The process request should not be deleted when retention policy is disabled'); // Re-enable for other tests Config::set('app.case_retention_policy_enabled', true); @@ -404,5 +426,100 @@ public function testItDefaultsToOneYearForProcessesWithoutRetentionPeriod() $this->assertDatabaseCount('case_numbers', 2); $this->assertDatabaseCount('cases_started', 1); $this->assertDatabaseCount('cases_participated', 1); + // ProcessRequest should not be deleted (some cases still exist) + $this->assertNotNull(ProcessRequest::find($processRequest->id), 'The process request should not be deleted when some cases still exist'); + } + + public function testItDeletesAllRelatedRecordsWhenCasesExceedRetentionPeriod() + { + // Create a process with a 1 year retention period + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + ], + ]); + $process->save(); + $process->refresh(); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + + // Delete the auto-created CaseNumber from ProcessRequestObserver + // so we can test ProcessRequest deletion when all cases are removed + CaseNumber::where('process_request_id', $processRequest->id)->delete(); + + // Create a process request token + $token = ProcessRequestToken::factory()->create([ + 'process_request_id' => $processRequest->id, + 'process_id' => $process->id, + ]); + + // Create a case number created 13 months ago (should be deleted) + $oldCaseCreatedAt = Carbon::now()->subMonths(13)->toIso8601String(); + $caseOld = CaseNumber::factory()->create([ + 'created_at' => $oldCaseCreatedAt, + 'process_request_id' => $processRequest->id, + ]); + + // Create related records + $casesStartedOld = CaseStarted::factory()->create([ + 'case_number' => $caseOld->id, + ]); + $casesParticipatedOld = CaseParticipated::factory()->create([ + 'case_number' => $caseOld->id, + ]); + + // Create comments + $commentOnRequest = Comment::factory()->create([ + 'commentable_type' => ProcessRequest::class, + 'commentable_id' => $processRequest->id, + 'case_number' => $caseOld->id, + ]); + $commentOnToken = Comment::factory()->create([ + 'commentable_type' => ProcessRequestToken::class, + 'commentable_id' => $token->id, + 'case_number' => $caseOld->id, + ]); + + // Create media + $requestMedia = Media::factory()->create([ + 'model_type' => ProcessRequest::class, + 'model_id' => $processRequest->id, + 'custom_properties' => [ + 'data_name' => 'case/file.txt', + ], + ]); + + // Create notification + $notification = Notification::factory()->create([ + 'data' => json_encode([ + 'request_id' => $processRequest->id, + 'type' => 'TASK_CREATED', + ]), + ]); + + // Dispatch the job to evaluate the retention period + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // Check that all case-related records have been deleted + $this->assertNull(CaseNumber::find($caseOld->id), 'The case number should be deleted'); + $this->assertNull(CaseStarted::find($casesStartedOld->id), 'The cases_started should be deleted'); + $this->assertNull(CaseParticipated::find($casesParticipatedOld->id), 'The cases_participated should be deleted'); + + // Check that ProcessRequest has been deleted + $this->assertNull(ProcessRequest::find($processRequest->id), 'The process request should be deleted'); + + // Check that comments have been deleted + $this->assertNull(Comment::find($commentOnRequest->id), 'The comment on ProcessRequest should be deleted'); + $this->assertNull(Comment::find($commentOnToken->id), 'The comment on ProcessRequestToken should be deleted'); + + // Check that media has been deleted + $this->assertNull(Media::find($requestMedia->id), 'The request media should be deleted'); + + // Check that notification has been deleted + $this->assertNull(Notification::find($notification->id), 'The notification should be deleted'); } } From 41f5474c2247436e2f57d29682f92d4c52e3862c Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:46:12 -0800 Subject: [PATCH 4/8] Add array support for $caseNumbers --- .../Api/Actions/Cases/DeletesCaseRecords.php | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php index f59850a67b..2e83e31173 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php @@ -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 @@ -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); @@ -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 From 67d352468a91f4e8f90141127d7c301ada0ae517 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:48:39 -0800 Subject: [PATCH 5/8] Add support for deleting all related assets; Utilize DeleteCasesRecords methods; --- .../Jobs/EvaluateProcessRetentionJob.php | 113 ++++++++++++++---- 1 file changed, 93 insertions(+), 20 deletions(-) diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index cd533260bf..8042565f05 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -6,15 +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. @@ -80,32 +83,102 @@ 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_started associated with the case_number - CaseStarted::whereIn('case_number', $caseIds)->delete(); - // Delete the cases_participated associated with the case_number - CaseParticipated::whereIn('case_number', $caseIds)->delete(); - // 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); + } + } + } + + /** + * 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(); } } From 09464d7b45192b4399c53219c3d40a9bf3c4baa4 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 4 Mar 2026 16:03:16 -0500 Subject: [PATCH 6/8] Update retention period formatting --- .../Console/Commands/EvaluateCaseRetention.php | 2 +- ProcessMaker/Jobs/EvaluateProcessRetentionJob.php | 14 +++++++------- tests/Jobs/EvaluateProcessRetentionJobTest.php | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php index 9404366d25..637042f114 100644 --- a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php +++ b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php @@ -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)); diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index 8042565f05..7af3e7ca37 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -44,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 diff --git a/tests/Jobs/EvaluateProcessRetentionJobTest.php b/tests/Jobs/EvaluateProcessRetentionJobTest.php index 98f157122d..48e35cfc42 100644 --- a/tests/Jobs/EvaluateProcessRetentionJobTest.php +++ b/tests/Jobs/EvaluateProcessRetentionJobTest.php @@ -21,7 +21,7 @@ class EvaluateProcessRetentionJobTest extends TestCase { use RefreshDatabase; - const RETENTION_PERIOD = '1_year'; + const RETENTION_PERIOD = 'one_year'; protected function setUp(): void { @@ -203,7 +203,7 @@ public function testItHandlesRetentionPolicyUpdate() $retentionUpdatedAt = Carbon::now()->subMonths(6)->toIso8601String(); $process = Process::factory()->create([ 'properties' => [ - 'retention_period' => '1_year', // Updated to 1 year + 'retention_period' => 'one_year', // Updated to 1 year 'retention_updated_at' => $retentionUpdatedAt, ], ]); @@ -249,7 +249,7 @@ public function testItDeletesOldCasesAfterRetentionPolicyUpdate() $retentionUpdatedAt = Carbon::now()->subMonths(6)->toIso8601String(); $process = Process::factory()->create([ 'properties' => [ - 'retention_period' => '1_year', // Updated to 1 year + 'retention_period' => 'one_year', // Updated to 1 year 'retention_updated_at' => $retentionUpdatedAt, ], ]); From 6a8acaea6c532cce76de677560ac754dfdd1cc4a Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 4 Mar 2026 16:06:48 -0500 Subject: [PATCH 7/8] Move dispatchSavedSearchRecount() for accessibility --- .../Controllers/Api/Actions/Cases/DeleteCase.php | 16 ---------------- .../Api/Actions/Cases/DeletesCaseRecords.php | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index 6d34bcf7b7..7dc266c2ea 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -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']); - }); - } } diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php index 2e83e31173..05bc9ebe00 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php @@ -241,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']); + }); + } } From 0f870bf43a1f40a3865dfc1d13b2139f0b60e9cb Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 4 Mar 2026 16:18:32 -0500 Subject: [PATCH 8/8] Recount saved searches after process request deletion --- ProcessMaker/Jobs/EvaluateProcessRetentionJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index 7af3e7ca37..29844b0e16 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -138,6 +138,8 @@ public function handle(): void // Delete any remaining related records $this->deleteRequestMedia($processRequestIdsWithNoCases); $this->deleteNotifications($processRequestIdsWithNoCases); + + $this->dispatchSavedSearchRecount(); } } }