diff --git a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php new file mode 100644 index 0000000000..61245fc915 --- /dev/null +++ b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php @@ -0,0 +1,74 @@ +info('Case retention policy is disabled'); + $this->error('Skipping case retention evaluation'); + + return; + } + + $this->info('Case retention policy is enabled'); + $this->info('Dispatching retention evaluation jobs for all processes'); + + // Get system category IDs to exclude + $systemCategoryIds = ProcessCategory::where('is_system', true)->pluck('id'); + + // Exclude processes that are templates or in system categories + $jobCount = 0; + $query = Process::where('is_template', '!=', 1); + + // Exclude processes in system categories + if ($systemCategoryIds->isNotEmpty()) { + $query->where(function ($q) use ($systemCategoryIds) { + $q->where(function ($subQuery) use ($systemCategoryIds) { + $subQuery->whereNotIn('process_category_id', $systemCategoryIds) + ->orWhereNull('process_category_id'); + }); + }) + ->whereDoesntHave('categories', function ($q) use ($systemCategoryIds) { + // Exclude processes with any category assignment to system categories + $q->whereIn('process_categories.id', $systemCategoryIds); + }); + } + + $query->chunkById(100, function ($processes) use (&$jobCount) { + foreach ($processes as $process) { + dispatch(new EvaluateProcessRetentionJob($process->id)); + $jobCount++; + } + }); + + $this->info("Dispatched {$jobCount} retention evaluation job(s) to the queue"); + $this->info('Jobs will be processed asynchronously by queue workers'); + } +} 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/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 f59850a67b..05bc9ebe00 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 @@ -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']); + }); + } } diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php new file mode 100644 index 0000000000..1e1df53c57 --- /dev/null +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -0,0 +1,251 @@ + $this->processId, + ]); + + // Only run if case retention policy is enabled + $enabled = config('app.case_retention_policy_enabled', false); + if (!$enabled) { + Log::info('EvaluateProcessRetentionJob: Case retention policy is disabled, skipping', [ + 'process_id' => $this->processId, + ]); + + return; + } + + $process = Process::find($this->processId); + if (!$process) { + Log::error('EvaluateProcessRetentionJob: Process not found', [ + 'process_id' => $this->processId, + ]); + + return; + } + + // Skip template processes + if ($process->is_template) { + Log::info('EvaluateProcessRetentionJob: Skipping template process', [ + 'process_id' => $this->processId, + ]); + + return; + } + + // Skip processes in system categories + if ($process->categories()->where('is_system', true)->exists()) { + Log::info('EvaluateProcessRetentionJob: Skipping process in system category', [ + 'process_id' => $this->processId, + ]); + + return; + } + + // Default to one_year if retention_period is not set + $retentionPeriod = $process->properties['retention_period'] ?? 'one_year'; + $retentionMonths = match ($retentionPeriod) { + 'six_months' => 6, + 'one_year' => 12, + 'three_years' => 36, + 'five_years' => 60, + default => 12, // Default to one_year + }; + + Log::info('EvaluateProcessRetentionJob: Retention configuration loaded', [ + 'process_id' => $this->processId, + 'process_name' => $process->name, + 'retention_period' => $retentionPeriod, + 'retention_months' => $retentionMonths, + ]); + + // 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 (!ProcessRequest::where('process_id', $this->processId)->exists()) { + Log::info('EvaluateProcessRetentionJob: No process requests found, nothing to evaluate', [ + 'process_id' => $this->processId, + ]); + + 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); + + Log::info('EvaluateProcessRetentionJob: Retention cutoff dates calculated', [ + 'process_id' => $this->processId, + 'retention_updated_at' => $retentionUpdatedAt->toIso8601String(), + 'old_cases_cutoff' => $oldCasesCutoff->toIso8601String(), + 'new_cases_cutoff' => $newCasesCutoff->toIso8601String(), + 'current_time' => $now->toIso8601String(), + ]); + + // 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 = []; + $totalDeleted = 0; + $chunkCount = 0; + + CaseNumber::whereIn('process_request_id', $processRequestSubquery) + ->where($this->buildRetentionQuery($retentionUpdatedAt, $oldCasesCutoff, $newCasesCutoff)) + ->chunkById(100, function ($cases) use (&$processRequestIdsToDelete, &$totalDeleted, &$chunkCount) { + $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); + + $chunkCount++; + $chunkSize = count($caseIds); + $totalDeleted += $chunkSize; + + Log::info('EvaluateProcessRetentionJob: Deleted chunk of cases', [ + 'process_id' => $this->processId, + 'chunk_number' => $chunkCount, + 'cases_deleted' => $chunkSize, + ]); + }); + + // 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(); + } + } + + $endTime = microtime(true); + $executionTime = round(($endTime - $startTime) * 1000, 2); + + Log::info('EvaluateProcessRetentionJob: Evaluation completed', [ + 'process_id' => $this->processId, + 'total_cases_deleted' => $totalDeleted, + 'total_chunks_processed' => $chunkCount, + 'execution_time_ms' => $executionTime, + ]); + } + + /** + * 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(); + } +} 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..1e747f3d67 --- /dev/null +++ b/tests/Jobs/EvaluateProcessRetentionJobTest.php @@ -0,0 +1,599 @@ +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); + + // 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 + $oldCaseCreatedAt = Carbon::now()->subMonths(13)->toIso8601String(); + $caseOld = CaseNumber::factory()->create([ + '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()); + + // 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), '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() + { + // 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, + ]); + // 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()); + + // 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), '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() + { + // 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); + + // 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 + // 13 months ago < 12 months ago, so these should be deleted + $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); + + // Dispatch the job to evaluate the retention period + EvaluateProcessRetentionJob::dispatchSync($process->id); + + // Assert all old cases are deleted + $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() + { + // 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' => 'one_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); + // 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() + { + // 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' => 'one_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(); + $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 + // 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(); + $casesStartedNotDeleted = CaseStarted::factory()->create([ + 'case_number' => $oldCaseNotDeleted->id, + ]); + $casesParticipatedNotDeleted = CaseParticipated::factory()->create([ + 'case_number' => $oldCaseNotDeleted->id, + ]); + + // 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->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); + // 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() + { + // 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(); + $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); + // 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); + } + + 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(); + $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); + $newCase = CaseNumber::factory()->create([ + 'process_request_id' => $processRequest->id, + ]); + $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); + + // 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->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); + // 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'); + } + + public function testItDoesNotRunForTemplates() + { + // Create a template process + $process = Process::factory()->create([ + 'is_template' => 1, + '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(); + + // Create an old case that should be deleted if this wasn't a template + // 13 months ago is older than 1 year retention period + $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 the process is a template + $this->assertNotNull(CaseNumber::find($oldCase->id), 'The case should NOT be deleted because the process is a template'); + $this->assertDatabaseCount('case_numbers', 2); + } + + public function testItDoesNotRunForProcessesInSystemCategories() + { + $category = ProcessCategory::factory()->create([ + 'is_system' => 1, + ]); + // Create a process in a system category + $process = Process::factory()->create([ + 'process_category_id' => $category->id, + '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(); + + // Create an old case that should be deleted if this wasn't in a system category + // 13 months ago is older than 1 year retention period + $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 the process is in a system category + $this->assertNotNull(CaseNumber::find($oldCase->id), 'The case should NOT be deleted because the process is in a system category'); + $this->assertDatabaseCount('case_numbers', 2); + } +}