diff --git a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php new file mode 100644 index 0000000000..9404366d25 --- /dev/null +++ b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php @@ -0,0 +1,52 @@ +info('Case retention policy is disabled'); + $this->error('Skipping case retention evaluation'); + + return; + } + + $this->info('Case retention policy is enabled'); + $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 + 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 7edd255225..03fc408949 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -90,6 +90,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..f5d2415993 --- /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 1_year if retention_period is not set + $retentionPeriod = $process->properties['retention_period'] ?? '1_year'; + $retentionMonths = match ($retentionPeriod) { + '6_months' => 6, + '1_year' => 12, + '3_years' => 36, + '5_years' => 60, + default => 12, // Default to 1_year + }; + + // 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(); + + // Check if there are any process requests for this process + // If not, nothing to delete + if (!ProcessRequest::where('process_id', $this->processId)->exists()) { + 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); + + // Use subquery to get process request IDs + $processRequestSubquery = ProcessRequest::where('process_id', $this->processId)->select('id'); + + 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 + 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/README.md b/README.md index 17789a6e1a..889bb63882 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,26 @@ How to use icon: npm run dev-font ``` +# Case Retention Tier (CASE_RETENTION_TIER) + +The case retention policy controls how long cases are stored before they are automatically and permanently deleted. The **CASE_RETENTION_TIER** environment variable determines which retention periods customers can select when configuring a process. Each tier exposes a different set of options in the UI; options for higher tiers are visible but disabled so users see what is available at higher tiers. + +### Supported tiers + +| Tier | Retention options available | +|------|----------------------------| +| **1** | Six months, One year | +| **2** | Six months, One year, Three years | +| **3** | Six months, One year, Three years, Five years | + +Set the variable in your `.env` file: +```env +CASE_RETENTION_POLICY_ENABLED=true +CASE_RETENTION_TIER=1 +``` +Use `1`, `2`, or `3`. The default is `1` if not set. The default retention period shown in the UI for Tier 1 is one year. + + # Prometheus and Grafana diff --git a/config/app.php b/config/app.php index 4d461545fd..229bca462c 100644 --- a/config/app.php +++ b/config/app.php @@ -288,6 +288,9 @@ // Enable or disable TCE customization feature 'tce_customization_enable' => env('TCE_CUSTOMIZATION_ENABLED', false), + // Enable or disable case retention policy + 'case_retention_policy_enabled' => env('CASE_RETENTION_POLICY_ENABLED', false), + 'prometheus_namespace' => env('PROMETHEUS_NAMESPACE', strtolower(preg_replace('/[^a-zA-Z0-9_]+/', '_', env('APP_NAME', 'processmaker')))), 'server_timing' => [ @@ -302,6 +305,18 @@ 'multitenancy' => env('MULTITENANCY', false), 'reassign_restrict_to_assignable_users' => env('REASSIGN_RESTRICT_TO_ASSIGNABLE_USERS', true), + + // When true, shows the Cases Retention section on process configuration + 'case_retention_policy_enabled' => filter_var(env('CASE_RETENTION_POLICY_ENABLED', false), FILTER_VALIDATE_BOOLEAN), + + // Controls which retention periods are available in the UI for the current tier. + 'case_retention_tier' => env('CASE_RETENTION_TIER', '1'), + 'case_retention_tier_options' => [ + '1' => ['six_months', 'one_year'], + '2' => ['six_months', 'one_year', 'three_years'], + '3' => ['six_months', 'one_year', 'three_years', 'five_years'], + ], + 'resources_core_path' => base_path('resources-core'), 'scheduler' => [ 'claim_timeout_minutes' => env('SCHEDULER_CLAIM_TIMEOUT_MINUTES', 5), diff --git a/devhub/pm-font/svg/check-circle-outline.svg b/devhub/pm-font/svg/check-circle-outline.svg new file mode 100644 index 0000000000..79932fc0c0 --- /dev/null +++ b/devhub/pm-font/svg/check-circle-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/devhub/pm-font/svg/exclamation-triangle.svg b/devhub/pm-font/svg/exclamation-triangle.svg new file mode 100644 index 0000000000..dc9fa5ea18 --- /dev/null +++ b/devhub/pm-font/svg/exclamation-triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/fonts/pm-font/index.html b/resources/fonts/pm-font/index.html index 90ee73e5e7..19b60faa9a 100644 --- a/resources/fonts/pm-font/index.html +++ b/resources/fonts/pm-font/index.html @@ -103,7 +103,7 @@
-

ProcessMaker Icons4.14.2

+

ProcessMaker Icons2026.2.4

Icons generated with svgtofont. For add new icons, please check the README file
@@ -115,7 +115,7 @@

ProcessMaker Icons4.14.2

-

ProcessMaker Icons4.14.2

+

ProcessMaker Icons2026.2.4

Icons generated with svgtofont. For add new icons, please check the README file
@@ -261,6 +261,13 @@

fp-brush-icon

fp-check-circle-blue

+
  • + +

    fp-check-circle-outline

    +
  • +
  • fp-desktop

    fp-edit-outline

  • +
  • + +

    fp-exclamation-triangle

    +
  • +
  • -

    ProcessMaker Icons4.14.2

    +

    ProcessMaker Icons2026.2.4

    Icons generated with svgtofont. For add new icons, please check the README file
    @@ -134,7 +134,7 @@

    ProcessMaker Icons4.14.2

      -
    • add-outlined

      &#59905;
    • arrow-left

      &#59906;
    • box-arrow-up-right

      &#59907;
    • bpmn-action-by-email

      &#59908;
    • bpmn-data-connector

      &#59909;
    • bpmn-data-object

      &#59910;
    • bpmn-data-store

      &#59911;
    • bpmn-docusign

      &#59912;
    • bpmn-end-event

      &#59913;
    • bpmn-flowgenie

      &#59914;
    • bpmn-gateway

      &#59915;
    • bpmn-generic-gateway

      &#59916;
    • bpmn-idp

      &#59917;
    • bpmn-intermediate-event

      &#59918;
    • bpmn-pool

      &#59919;
    • bpmn-send-email

      &#59920;
    • bpmn-start-event

      &#59921;
    • bpmn-task

      &#59922;
    • bpmn-text-annotation

      &#59923;
    • brush-icon

      &#59924;
    • check-circle-blue

      &#59925;
    • close

      &#59926;
    • cloud-download-outline

      &#59927;
    • connector-outline

      &#59928;
    • copy-outline

      &#59929;
    • copy

      &#59930;
    • desktop

      &#59931;
    • edit-outline

      &#59932;
    • expand

      &#59933;
    • eye

      &#59934;
    • fields-icon

      &#59935;
    • flowgenie-outline

      &#59936;
    • folder-outline

      &#59937;
    • fullscreen

      &#59938;
    • github

      &#59939;
    • inbox

      &#59940;
    • layout-icon

      &#59941;
    • link-icon

      &#59942;
    • map

      &#59943;
    • minimize

      &#59944;
    • mobile

      &#59945;
    • pdf

      &#59946;
    • pen-edit

      &#59947;
    • play-outline

      &#59948;
    • plus-thin

      &#59949;
    • plus

      &#59950;
    • pm-block

      &#59951;
    • remove-outlined

      &#59952;
    • screen-outline

      &#59953;
    • script-outline

      &#59954;
    • slack-notification

      &#59955;
    • slack

      &#59956;
    • slideshow

      &#59957;
    • table

      &#59958;
    • tachometer-alt-average

      &#59959;
    • trash-blue

      &#59960;
    • trash

      &#59961;
    • unlink

      &#59962;
    • update-outline

      &#59963;
    • +
    • add-outlined

      &#59905;
    • arrow-left

      &#59906;
    • box-arrow-up-right

      &#59907;
    • bpmn-action-by-email

      &#59908;
    • bpmn-data-connector

      &#59909;
    • bpmn-data-object

      &#59910;
    • bpmn-data-store

      &#59911;
    • bpmn-docusign

      &#59912;
    • bpmn-end-event

      &#59913;
    • bpmn-flowgenie

      &#59914;
    • bpmn-gateway

      &#59915;
    • bpmn-generic-gateway

      &#59916;
    • bpmn-idp

      &#59917;
    • bpmn-intermediate-event

      &#59918;
    • bpmn-pool

      &#59919;
    • bpmn-send-email

      &#59920;
    • bpmn-start-event

      &#59921;
    • bpmn-task

      &#59922;
    • bpmn-text-annotation

      &#59923;
    • brush-icon

      &#59924;
    • check-circle-blue

      &#59925;
    • check-circle-outline

      &#59926;
    • close

      &#59927;
    • cloud-download-outline

      &#59928;
    • connector-outline

      &#59929;
    • copy-outline

      &#59930;
    • copy

      &#59931;
    • desktop

      &#59932;
    • edit-outline

      &#59933;
    • exclamation-triangle

      &#59934;
    • expand

      &#59935;
    • eye

      &#59936;
    • fields-icon

      &#59937;
    • flowgenie-outline

      &#59938;
    • folder-outline

      &#59939;
    • fullscreen

      &#59940;
    • github

      &#59941;
    • inbox

      &#59942;
    • layout-icon

      &#59943;
    • link-icon

      &#59944;
    • map

      &#59945;
    • minimize

      &#59946;
    • mobile

      &#59947;
    • pdf

      &#59948;
    • pen-edit

      &#59949;
    • play-outline

      &#59950;
    • plus-thin

      &#59951;
    • plus

      &#59952;
    • pm-block

      &#59953;
    • remove-outlined

      &#59954;
    • screen-outline

      &#59955;
    • script-outline

      &#59956;
    • slack-notification

      &#59957;
    • slack

      &#59958;
    • slideshow

      &#59959;
    • table

      &#59960;
    • tachometer-alt-average

      &#59961;
    • trash-blue

      &#59962;
    • trash

      &#59963;
    • unlink

      &#59964;
    • update-outline

      &#59965;
  • + @if(config('app.case_retention_policy_enabled')) +
    +
    + +
    +
    +
    + + +
    +
    {{ __('Retention Policy') }}
    +

    {{ __('Each case in this process is retained from the moment it is created for the period defined in this section.')}}

    +

    {{ __('After this period expires, the case is automatically and ') }}{{ __('permanently deleted') }}{{ __(', regardless of its status. This deletion includes all files and all data associated with the case and cannot be undone.') }}

    +
    +
    + +
    +
    {{ __('Retention Period') }}
    +

    {{ __('Retention periods over one year must be handled by Technical Support. Please contact Technical Support for assistance.')}}

    +
    +
    + {{ html()->label(__('Select a Retention Period'), 'selectRetentionPeriod') }} + + + +
    +
    + {{ __('The default retention period is in effect.')}} + + {{ __('Last modified by: ') }}@{{ retentionUpdatedBy.fullname.trim() }}{{ __(', at') }} @{{formatDateUser(retentionUpdatedBy.date) }} +
    + + + + +
    +
    +
    +
    +
    + @endif +
    {{ html()->button(__('Cancel'), 'button')->class('btn btn-outline-secondary button-custom')->attribute('@click', 'onClose') }} {{ html()->button(__('Save'), 'button')->class('btn btn-secondary ml-3 button-custom')->attribute('@click', 'onUpdate') }} @@ -559,6 +677,25 @@ class="custom-control-input"> groups: [] }, maxManagers: 10, + retentionPeriodOptions: [ + { id: 'six_months', fullname: 'Six months after case creation' }, + { id: 'one_year', fullname: 'One year after case creation' }, + { id: 'three_years', fullname: 'Three years after case creation' }, + { id: 'five_years', fullname: 'Five years after case creation' } + ], + canSelectRetentionPeriod: { id: 'one_year', fullname: 'One year after case creation' }, + allowedRetentionPeriods: @json(config('app.case_retention_tier_options')[config('app.case_retention_tier')] ?? ['six_months', 'one_year']), + showRetentionConfirmModal: false, + retentionModalStep: 'confirm', + pendingRetentionPeriod: null, + caseRetentionPolicyEnabled: @json(config('app.case_retention_policy_enabled')), + lastConfirmedRetentionPeriod: null, + originalRetentionPeriodId: null, + retentionUpdatedBy: { + id: null, + fullname: null, + date: null, + }, } }, mounted() { @@ -581,8 +718,35 @@ class="custom-control-input"> this.activeTab = target[1]; } + if (this.caseRetentionPolicyEnabled) { + const savedId = _.get(this.formData, 'properties.retention_period'); + const allowed = this.allowedRetentionPeriods || []; + const option = this.retentionPeriodOptions.find(opt => opt.id === savedId); + if (option && allowed.includes(savedId)) { + this.canSelectRetentionPeriod = option; + } else { + const defaultId = allowed.includes('one_year') ? 'one_year' : allowed[0]; + this.canSelectRetentionPeriod = this.retentionPeriodOptions.find(opt => opt.id === defaultId) || null; + } + } + + this.lastConfirmedRetentionPeriod = this.canSelectRetentionPeriod; + this.originalRetentionPeriodId = this.canSelectRetentionPeriod ? this.canSelectRetentionPeriod.id : null; + this.retentionUpdatedBy = { + id: _.get(this.formData, 'properties.retention_updated_by.id'), + fullname: _.get(this.formData, 'properties.retention_updated_by.fullname'), + date: _.get(this.formData, 'properties.retention_updated_at'), + }; }, computed: { + retentionPeriodSelectOptions() { + const allowed = this.allowedRetentionPeriods || []; + + return this.retentionPeriodOptions.map(opt => ({ + ...opt, + $isDisabled: !allowed.includes(opt.id) + })); + }, activeUsersAndGroupsWithManager() { const usersAndGroups = _.cloneDeep(this.activeUsersAndGroups); usersAndGroups[0]['items'].unshift(this.processManagerOption()); @@ -679,6 +843,30 @@ class="custom-control-input"> this.formData.manager_id = this.formatManagerId(this.manager); this.formData.user_id = this.formatValueScreen(this.owner); this.formData.reassignment_permissions = this.reassignmentPermissions; + if (this.caseRetentionPolicyEnabled) { + this.formData.properties = this.formData.properties || {}; + const retentionPeriod = this.canSelectRetentionPeriod + ? this.canSelectRetentionPeriod.id + : this.getDefaultRetentionPeriodId(); + this.formData.properties.retention_period = retentionPeriod; + // Log retention period update only if the retention period is changed from the original value + if (this.formData.properties.retention_period !== this.originalRetentionPeriodId) { + // The logged in user is the one who updated the retention period + const userID = document.head.querySelector("meta[name=\"user-id\"]"); + const userFullName = document.head.querySelector("meta[name=\"user-full-name\"]"); + this.formData.properties.retention_updated_by = { + id: userID.content, + fullname: userFullName.content, + }; + const updatedAt = new Date().toISOString(); + this.formData.properties.retention_updated_at = updatedAt; + this.retentionUpdatedBy = { + id: parseInt(userID?.content ?? 0), + fullname: userFullName?.content ?? '', + date: updatedAt, + }; + } + } ProcessMaker.apiClient.put('processes/' + that.formData.id, that.formData) .then(response => { @@ -708,11 +896,69 @@ class="custom-control-input"> }, reassignmentClicked() { this.$refs["listReassignment"].add(); + }, + getDefaultRetentionPeriodId() { + const allowed = this.allowedRetentionPeriods || []; + return allowed.includes('one_year') ? 'one_year' : (allowed[0] || null); + }, + onRetentionPeriodSelect(newVal) { + if (!newVal || !this.lastConfirmedRetentionPeriod) { + return; + } + + if (newVal.id === this.lastConfirmedRetentionPeriod.id) { + return; + } + + this.pendingRetentionPeriod = newVal; + this.retentionModalStep = 'confirm'; + this.showRetentionConfirmModal = true; + }, + confirmRetentionChange() { + if (this.pendingRetentionPeriod) { + this.canSelectRetentionPeriod = this.pendingRetentionPeriod; + this.lastConfirmedRetentionPeriod = this.pendingRetentionPeriod; + + this.formData.properties = this.formData.properties || {}; + this.formData.properties.retention_period = this.pendingRetentionPeriod.id; + } + + this.retentionModalStep = 'success'; + }, + cancelRetentionChange() { + this.canSelectRetentionPeriod = this.lastConfirmedRetentionPeriod; + this.pendingRetentionPeriod = null; + this.retentionModalStep = 'confirm'; + this.showRetentionConfirmModal = false; + }, + closeRetentionSuccessModal() { + this.showRetentionConfirmModal = false; + this.pendingRetentionPeriod = null; + }, + onRetentionModalHide() { + if (this.retentionModalStep === 'confirm') { + this.cancelRetentionChange(); + } + }, + formatDateUser(value) { + let config = ""; + if (typeof ProcessMaker !== "undefined" && ProcessMaker.user && ProcessMaker.user.datetime_format) { + config = ProcessMaker.user.datetime_format; + } + + if (value) { + if (moment(value).isValid()) { + return window.moment(value) + .format(config); + } + return value; + } + return "n/a"; } }, + }); }); - @endsection @@ -875,5 +1121,95 @@ class="custom-control-input"> letter-spacing: -0.02em; text-align: center; } + + .retention-body { + color: #556271; + font-family: 'Inter', sans-serif; + } + + .retention-header { + font-weight: 600; + } + + .retention-text { + font-size: 16px; + } + + .retention-policy { + border-radius: 16px; + background-color: #FFFCF2; + } + + .default-retention { + background-color: #F1F2F4; + border-radius: 8px; + } + + .default-retention-icon { + font-size: 20px; + color: #039838; + } + + .warning-icon-container { + background: linear-gradient(180deg, #FEE6E5 0%, #FBD0D0 100%); + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + margin: 0 auto 16px; + } + + .warning-icon { + font-size: 30px; + color: #E51523; + } + + .retention-modal-text { + color: #464646; + font-family: 'Inter', sans-serif !important; + } + + .retention-modal-footer-btn { + text-transform: none !important; + font-weight: 500; + font-family: 'Inter', sans-serif !important; + border-radius: 8px; + } + + .confirm-period-btn { + background-color: #E51523 !important; + border-color: #E51523 !important; + color: white !important; + + &:hover { + background-color: #d54a52 !important; + border-color: #d54a52 !important; + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(236, 89, 98, 0.25) !important; + } + } + + .gap-2 { + gap: .5rem; + } + + .success-icon { + font-size: 30px; + color: #039838; + } + + .success-icon-container { + background: linear-gradient(180deg, #E6F9EB 0%, #D0F2E1 100%); + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + } @endsection diff --git a/tests/Jobs/EvaluateProcessRetentionJobTest.php b/tests/Jobs/EvaluateProcessRetentionJobTest.php new file mode 100644 index 0000000000..e8c1706de8 --- /dev/null +++ b/tests/Jobs/EvaluateProcessRetentionJobTest.php @@ -0,0 +1,331 @@ +create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + ], + ]); + + $process->save(); + $process->refresh(); + $this->assertEquals(self::RETENTION_PERIOD, $process->properties['retention_period']); + + // 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 + // Cutoff = now - 12 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 1 year retention period + // retention_updated_at defaults to now, so cutoff is 12 months ago (now - 12 months) + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + ], + ]); + $process->save(); + $process->refresh(); + $this->assertEquals(self::RETENTION_PERIOD, $process->properties['retention_period']); + + // 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 + // Cutoff = now - 12 months = 12 months ago + // 5 months ago is NOT < 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 1 year retention period + // retention_updated_at defaults to now, so cutoff is 12 months ago (now - 12 months) + $process = Process::factory()->create([ + 'properties' => [ + 'retention_period' => self::RETENTION_PERIOD, + ], + ]); + $process->save(); + $process->refresh(); + $this->assertEquals(self::RETENTION_PERIOD, $process->properties['retention_period']); + + // 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 + // 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(), + ]); + $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 recently 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 + Config::set('app.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 + Config::set('app.case_retention_policy_enabled', true); + } + + public function testItDefaultsToOneYearForProcessesWithoutRetentionPeriod() + { + // Create a process WITHOUT retention_period property (should default to 1 year) + $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 13 months ago (older than default 1 year retention) + // Since retention_updated_at defaults to now, old cases cutoff = now - 1 year + // 13 months ago < (now - 1 year), so it should be deleted + $oldCaseDate = Carbon::now()->subMonths(13); + $oldCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $oldCase->created_at = $oldCaseDate; + $oldCase->save(); + + // 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); + $newCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $newCase->created_at = $newCaseDate; + $newCase->save(); + + // Dispatch the job + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // The 13-month-old case should be deleted (older than 1 year default) + // 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->assertNotNull(CaseNumber::find($newCase->id), 'The 5-month-old case should NOT be deleted with default 1 year retention'); + $this->assertDatabaseCount('case_numbers', 2); + } +}