diff --git a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php new file mode 100644 index 0000000000..7a69059652 --- /dev/null +++ b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php @@ -0,0 +1,42 @@ +info('Evaluating and deleting cases past their retention period'); + + // Process all processes when retention policy is enabled + // Processes without retention_period will default to 6 months + Process::chunkById(100, function ($processes) { + foreach ($processes as $process) { + dispatch(new EvaluateProcessRetentionJob($process->id)); + } + }); + + $this->info('Cases retention evaluation complete'); + } +} diff --git a/ProcessMaker/Console/Kernel.php b/ProcessMaker/Console/Kernel.php index 1d6ee38a81..3e090f3765 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -89,6 +89,13 @@ protected function schedule(Schedule $schedule) break; } + // evaluate cases retention policy + $schedule->command('cases:retention:evaluate') + ->daily() + ->onOneServer() + ->withoutOverlapping() + ->runInBackground(); + // 5 minutes is recommended in https://laravel.com/docs/12.x/horizon#metrics $schedule->command('horizon:snapshot')->everyFiveMinutes(); } diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php new file mode 100644 index 0000000000..760224c68a --- /dev/null +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -0,0 +1,105 @@ +processId); + if (!$process) { + Log::error('CaseRetentionJob: Process not found', ['process_id' => $this->processId]); + + return; + } + + // Default to 6 months if retention_period is not set + $retentionPeriod = $process->properties['retention_period'] ?? '6_months'; + $retentionMonths = match ($retentionPeriod) { + '6_months' => 6, + '1_year' => 12, + '3_years' => 36, + '5_years' => 60, + default => 6, // Default to 6 months + }; + + // Default retention_updated_at to now if not set + // This means the retention policy applies from now for processes without explicit retention settings + $retentionUpdatedAt = isset($process->properties['retention_updated_at']) + ? Carbon::parse($process->properties['retention_updated_at']) + : Carbon::now(); + + // Get all process request IDs for this process + $processRequestIds = ProcessRequest::where('process_id', $this->processId)->pluck('id'); + + // If there are no process requests, nothing to delete + if ($processRequestIds->isEmpty()) { + return; + } + + // Handle two scenarios: + // 1. Cases created BEFORE retention_updated_at: Delete if older than retention period from retention_updated_at + // (These cases were subject to the old retention policy, but we apply current retention from update date) + // 2. Cases created AFTER retention_updated_at: Delete if older than retention period from their creation date + // (These cases are subject to the new retention policy) + + $now = Carbon::now(); + + // For cases created before retention_updated_at: cutoff is retention_updated_at - retention_period + $oldCasesCutoff = $retentionUpdatedAt->copy()->subMonths($retentionMonths); + + // For cases created after retention_updated_at: cutoff is now - retention_period + $newCasesCutoff = $now->copy()->subMonths($retentionMonths); + + CaseNumber::whereIn('process_request_id', $processRequestIds) + ->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 + CaseNumber::whereIn('id', $caseIds)->delete(); + + // TODO: Add logs to track the number of cases deleted + // Get deleted timestamp + // $deletedAt = Carbon::now(); + // RetentionPolicyLog::record($process->id, $caseIds, $deletedAt); + }); + } +} diff --git a/ProcessMaker/Models/CaseNumber.php b/ProcessMaker/Models/CaseNumber.php index 7b06ee69e5..721f61b019 100644 --- a/ProcessMaker/Models/CaseNumber.php +++ b/ProcessMaker/Models/CaseNumber.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Models; +use Database\Factories\ProcessMaker\Models\CaseNumberFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; diff --git a/database/factories/ProcessMaker/Models/CaseNumberFactory.php b/database/factories/ProcessMaker/Models/CaseNumberFactory.php new file mode 100644 index 0000000000..1c0adf4198 --- /dev/null +++ b/database/factories/ProcessMaker/Models/CaseNumberFactory.php @@ -0,0 +1,21 @@ + function () { + return ProcessRequest::factory()->create()->getKey(); + }, + ]; + } +} diff --git a/tests/Jobs/EvaluateProcessRetentionJobTest.php b/tests/Jobs/EvaluateProcessRetentionJobTest.php new file mode 100644 index 0000000000..50a74d2025 --- /dev/null +++ b/tests/Jobs/EvaluateProcessRetentionJobTest.php @@ -0,0 +1,344 @@ +subMonths(6)->toIso8601String(); + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + 'retention_updated_at' => $retentionUpdatedAt, + ], + ]); + + $process->save(); + $process->refresh(); + $this->assertEquals(self::RETENTION_PERIOD, $process->properties['retention_period']); + $this->assertEquals($retentionUpdatedAt, $process->properties['retention_updated_at']); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + $this->assertEquals($process->id, $processRequest->process_id); + + // Create a case number created 13 months ago (before retention_updated_at) + // Old cases cutoff = 6 months ago - 6 months = 12 months ago + // 13 months ago < 12 months ago, so it should be deleted + $oldCaseCreatedAt = Carbon::now()->subMonths(13)->toIso8601String(); + $caseOld = CaseNumber::factory()->create([ + 'created_at' => $oldCaseCreatedAt, + 'process_request_id' => $processRequest->id, + ]); + $this->assertEquals($processRequest->id, $caseOld->process_request_id); + $this->assertEquals($oldCaseCreatedAt, $caseOld->created_at->toIso8601String()); + + // Dispatch the job to evaluate the retention period + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // Check that the case old has been deleted + $this->assertNull(CaseNumber::find($caseOld->id)); + } + + public function testItDoesNotDeleteCasesThatAreWithinRetentionPeriod() + { + // Create a process with a 6 month retention period + // retention_updated_at is 6 months ago, so old cases cutoff is 12 months ago (6 months ago - 6 months) + $retentionUpdatedAt = Carbon::now()->subMonths(6)->toIso8601String(); + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + 'retention_updated_at' => $retentionUpdatedAt, + ], + ]); + $process->save(); + $process->refresh(); + $this->assertEquals(self::RETENTION_PERIOD, $process->properties['retention_period']); + $this->assertEquals($retentionUpdatedAt, $process->properties['retention_updated_at']); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + $this->assertEquals($process->id, $processRequest->process_id); + + // Create a case number created 5 months ago (before retention_updated_at) + // This case is NOT older than the old cases cutoff (12 months ago), so it should NOT be deleted + $caseCreatedAt = Carbon::now()->subMonths(5)->toIso8601String(); + $case = CaseNumber::factory()->create([ + 'created_at' => $caseCreatedAt, + 'process_request_id' => $processRequest->id, + ]); + $this->assertEquals($processRequest->id, $case->process_request_id); + $this->assertEquals($caseCreatedAt, $case->created_at->toIso8601String()); + + // Dispatch the job to evaluate the retention period + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // Check that the case has not been deleted + $this->assertNotNull(CaseNumber::find($case->id)); + } + + public function testItHandlesMultipleCasesInBatches() + { + // Create a process with a 6 month retention period + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + 'retention_updated_at' => Carbon::now()->subMonths(6)->toIso8601String(), + ], + ]); + $process->save(); + $process->refresh(); + $this->assertEquals(self::RETENTION_PERIOD, $process->properties['retention_period']); + $this->assertEquals(Carbon::now()->subMonths(6)->toIso8601String(), $process->properties['retention_updated_at']); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + $this->assertEquals($process->id, $processRequest->process_id); + + // Create 1200 cases (to test chunking/batch deletion) + // These cases are created 13 months ago (before retention_updated_at) + // Old cases cutoff = 6 months ago - 6 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(), + ]); + $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); + + // Assert all old cases are deleted + // There should be 1 case left (the auto-created case from ProcessRequestObserver) + // because it was created after retention_updated_at and is within the retention period + $this->assertDatabaseCount('case_numbers', 1); + } + + public function testItHandlesRetentionPolicyUpdate() + { + // Create a process with retention updated 6 months ago (was 6 months, now 1 year) + $retentionUpdatedAt = Carbon::now()->subMonths(6)->toIso8601String(); + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => '1_year', // Updated to 1 year + 'retention_updated_at' => $retentionUpdatedAt, + ], + ]); + $process->save(); + $process->refresh(); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + + // Create an old case (7 months ago, before retention_updated_at) + // Old cases cutoff = 6 months ago - 1 year = 18 months ago + // 7 months ago is NOT < 18 months ago, so it should NOT be deleted + $oldCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + 'created_at' => Carbon::now()->subMonths(7)->toIso8601String(), + ]); + + // Create a new case (1 month ago, after retention_updated_at) + // New cases cutoff = now - 1 year = 12 months ago + // 1 month ago is NOT < 12 months ago, so it should NOT be deleted + $newCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + 'created_at' => Carbon::now()->subMonths(1)->toIso8601String(), + ]); + + // Dispatch the job + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // Both cases should still exist (plus the auto-created one = 3 total) + $this->assertNotNull(CaseNumber::find($oldCase->id)); + $this->assertNotNull(CaseNumber::find($newCase->id)); + $this->assertDatabaseCount('case_numbers', 3); + } + + public function testItDeletesOldCasesAfterRetentionPolicyUpdate() + { + // Create a process with retention updated 6 months ago (was 6 months, now 1 year) + $retentionUpdatedAt = Carbon::now()->subMonths(6)->toIso8601String(); + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => '1_year', // Updated to 1 year + 'retention_updated_at' => $retentionUpdatedAt, + ], + ]); + $process->save(); + $process->refresh(); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + + // Create an old case (20 months ago, before retention_updated_at which is 6 months ago) + // Old cases cutoff = 6 months ago - 1 year = 18 months ago + // 20 months ago < 18 months ago (earlier date), so it SHOULD be deleted + $oldCaseDate = Carbon::now()->subMonths(20); + $oldCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $oldCase->created_at = $oldCaseDate; + $oldCase->save(); + + // 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 + // 7 months ago is NOT < 18 months ago (7 months ago is more recent), so it should NOT be deleted + $oldCaseNotDeletedDate = Carbon::now()->subMonths(7); + $oldCaseNotDeleted = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $oldCaseNotDeleted->created_at = $oldCaseNotDeletedDate; + $oldCaseNotDeleted->save(); + + // Dispatch the job + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // The 20-month-old case should be deleted (older than 18 months cutoff) + // 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->assertNotNull(CaseNumber::find($oldCaseNotDeleted->id), 'The 7-month-old case should NOT be deleted'); + $this->assertDatabaseCount('case_numbers', 2); + } + + public function testItDoesNotRunWhenRetentionPolicyIsDisabled() + { + // Disable case retention policy + putenv('CASE_RETENTION_POLICY_ENABLED=false'); + $_ENV['CASE_RETENTION_POLICY_ENABLED'] = 'false'; + $_SERVER['CASE_RETENTION_POLICY_ENABLED'] = 'false'; + + // Create a process with a 6 month retention period + $retentionUpdatedAt = Carbon::now()->subMonths(6)->toIso8601String(); + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + 'retention_updated_at' => $retentionUpdatedAt, + ], + ]); + $process->save(); + $process->refresh(); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + + // Create an old case that should be deleted if retention was enabled + $oldCaseDate = Carbon::now()->subMonths(13); + $oldCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $oldCase->created_at = $oldCaseDate; + $oldCase->save(); + + // 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->assertDatabaseCount('case_numbers', 2); + + // Re-enable for other tests + putenv('CASE_RETENTION_POLICY_ENABLED=true'); + $_ENV['CASE_RETENTION_POLICY_ENABLED'] = 'true'; + $_SERVER['CASE_RETENTION_POLICY_ENABLED'] = 'true'; + } + + public function testItDefaultsToSixMonthsForProcessesWithoutRetentionPeriod() + { + // Create a process WITHOUT retention_period property (should default to 6 months) + $process = Process::factory()->create([ + 'properties' => [], // No retention_period set + ]); + $process->save(); + $process->refresh(); + + // Create a process request + $processRequest = ProcessRequest::factory()->create(); + $processRequest->process_id = $process->id; + $processRequest->save(); + $processRequest->refresh(); + + // Create a case created 7 months ago (older than default 6 months retention) + // Since retention_updated_at defaults to now, old cases cutoff = now - 6 months + // 7 months ago < (now - 6 months), so it should be deleted + $oldCaseDate = Carbon::now()->subMonths(7); + $oldCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $oldCase->created_at = $oldCaseDate; + $oldCase->save(); + + // Create a case created 5 months ago (within default 6 months retention) + // 5 months ago is NOT < (now - 6 months), so it should NOT be deleted + $newCaseDate = Carbon::now()->subMonths(5); + $newCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $newCase->created_at = $newCaseDate; + $newCase->save(); + + // Dispatch the job + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // The 7-month-old case should be deleted (older than 6 months default) + // The 5-month-old case should NOT be deleted (within 6 months default) + // Plus the auto-created case = 2 total + $this->assertNull(CaseNumber::find($oldCase->id), 'The 7-month-old case should be deleted with default 6-month retention'); + $this->assertNotNull(CaseNumber::find($newCase->id), 'The 5-month-old case should NOT be deleted with default 6-month retention'); + $this->assertDatabaseCount('case_numbers', 2); + } +}