diff --git a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php index 61245fc915..6c9a615370 100644 --- a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php +++ b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php @@ -6,6 +6,7 @@ use ProcessMaker\Jobs\EvaluateProcessRetentionJob; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessCategory; +use ProcessMaker\Services\CaseRetentionTierService; class EvaluateCaseRetention extends Command { @@ -39,6 +40,8 @@ public function handle() $this->info('Case retention policy is enabled'); $this->info('Dispatching retention evaluation jobs for all processes'); + // Get the allowed periods for the current tier (support for downgrading to a lower tier) + $tierAllowedPeriods = CaseRetentionTierService::allowedPeriodsForCurrentTier(); // Get system category IDs to exclude $systemCategoryIds = ProcessCategory::where('is_system', true)->pluck('id'); @@ -61,9 +64,9 @@ public function handle() }); } - $query->chunkById(100, function ($processes) use (&$jobCount) { + $query->chunkById(100, function ($processes) use (&$jobCount, $tierAllowedPeriods) { foreach ($processes as $process) { - dispatch(new EvaluateProcessRetentionJob($process->id)); + dispatch(new EvaluateProcessRetentionJob($process->id, $tierAllowedPeriods)); $jobCount++; } }); diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index 8080fa53c9..9968e050fe 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -44,6 +44,7 @@ use ProcessMaker\Package\WebEntry\Models\WebentryRoute; use ProcessMaker\Providers\WorkflowServiceProvider; use ProcessMaker\Rules\BPMNValidation; +use ProcessMaker\Services\CaseRetentionTierService; use ProcessMaker\Traits\ProjectAssetTrait; use Throwable; @@ -224,6 +225,11 @@ public function index(Request $request) // Get the launchpad configuration $process->launchpad = ProcessLaunchpad::getLaunchpad($launchpad, $process->id); + $process->case_retention_tier_adjustment_notice = false; + if ($user->is_administrator && config('app.case_retention_policy_enabled')) { + $process->case_retention_tier_adjustment_notice = CaseRetentionTierService::adjustmentNoticeIsActive($process); + } + // Filter all processes that have event definitions (start events like message event, conditional event, signal event, timer event) if ($request->has('without_event_definitions') && $request->input('without_event_definitions') == 'true') { $startEvents = $process->events->filter(function ($event) { @@ -606,6 +612,12 @@ public function update(Request $request, Process $process) $this->restoreProcessRetentionPropertiesFromOriginal($process, $original); } + if (auth()->user()->is_administrator && $request->has('properties') && is_array($request->input('properties')) && array_key_exists('retention_period', $request->input('properties'))) { + $properties = $process->properties ?? []; + unset($properties[CaseRetentionTierService::NOTICE_PROPERTY_KEY], $properties[CaseRetentionTierService::NOTICE_AT_PROPERTY_KEY]); + $process->properties = $properties; + } + // Catch errors to send more specific status try { $process->saveOrFail(); @@ -697,7 +709,13 @@ private function restoreProcessRetentionPropertiesFromOriginal(Process $process, $properties = []; } - $keys = ['retention_updated_by', 'retention_updated_at', 'retention_period']; + $keys = [ + 'retention_updated_by', + 'retention_updated_at', + 'retention_period', + CaseRetentionTierService::NOTICE_PROPERTY_KEY, + CaseRetentionTierService::NOTICE_AT_PROPERTY_KEY, + ]; foreach ($keys as $key) { if (array_key_exists($key, $originalProperties)) { $properties[$key] = $originalProperties[$key]; diff --git a/ProcessMaker/Http/Resources/Process.php b/ProcessMaker/Http/Resources/Process.php index 25a79bca5a..a3743a925c 100644 --- a/ProcessMaker/Http/Resources/Process.php +++ b/ProcessMaker/Http/Resources/Process.php @@ -28,6 +28,7 @@ public function toArray($request) $array['svg'] = $this->svg; } $array['manager_id'] = $this->manager_id; + $array['case_retention_tier_adjustment_notice'] = (bool) ($this->resource->case_retention_tier_adjustment_notice ?? false); return $array; } diff --git a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index e5b14a267d..5ca45cfec0 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -15,16 +15,20 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\TaskDraft; +use ProcessMaker\Services\CaseRetentionTierService; class EvaluateProcessRetentionJob implements ShouldQueue { use Queueable, DeletesCaseRecords; /** - * Create a new job instance. + * @param list|null $tierAllowedPeriods From {@see EvaluateCaseRetention} so tier options are not + * re-resolved for every queued process; null = resolve in job. */ - public function __construct(public int $processId) - { + public function __construct( + public int $processId, + public ?array $tierAllowedPeriods = null, + ) { } /** @@ -75,6 +79,14 @@ public function handle(): void return; } + if (CaseRetentionTierService::clampProcessRetentionToCurrentTier($process, $this->tierAllowedPeriods)) { + Log::info('EvaluateProcessRetentionJob: Retention period clamped to current tier maximum', [ + 'process_id' => $this->processId, + 'retention_period' => $process->properties['retention_period'] ?? null, + ]); + $process->refresh(); + } + // Default to one_year if retention_period is not set $retentionPeriod = $process->properties['retention_period'] ?? 'one_year'; $retentionMonths = match ($retentionPeriod) { diff --git a/ProcessMaker/Services/CaseRetentionTierService.php b/ProcessMaker/Services/CaseRetentionTierService.php new file mode 100644 index 0000000000..85bd53d7b5 --- /dev/null +++ b/ProcessMaker/Services/CaseRetentionTierService.php @@ -0,0 +1,129 @@ + */ + private const PERIOD_MONTHS = [ + 'six_months' => 6, + 'one_year' => 12, + 'three_years' => 36, + 'five_years' => 60, + ]; + + /** + * Whether the process-listing warning for a tier-driven retention clamp should show (admins only). + */ + public static function adjustmentNoticeIsActive(Process $process): bool + { + $props = $process->properties ?? []; + $at = $props[self::NOTICE_AT_PROPERTY_KEY] ?? null; + if (is_string($at) && $at !== '') { + return Carbon::parse($at)->greaterThan(now()->subHours(self::NOTICE_DURATION_HOURS)); + } + + if (filter_var($props[self::NOTICE_PROPERTY_KEY] ?? false, FILTER_VALIDATE_BOOLEAN)) { + $updatedAt = $process->updated_at; + + return $updatedAt && Carbon::parse($updatedAt)->greaterThan(now()->subHours(self::NOTICE_DURATION_HOURS)); + } + + return false; + } + + /** + * Retention period options allowed for the configured CASE_RETENTION_TIER. + * + * @return list + */ + public static function allowedPeriodsForCurrentTier(): array + { + $tier = (string) config('app.case_retention_tier', '1'); + $options = config('app.case_retention_tier_options', []); + + return $options[$tier] ?? $options['1'] ?? ['six_months', 'one_year']; + } + + /** + * Longest retention period in the allowed list (by duration), not by array order. + * + * @param list $allowed + */ + public static function longestAllowedPeriod(array $allowed): string + { + $best = 'one_year'; + $bestMonths = 0; + foreach ($allowed as $period) { + if (!is_string($period)) { + continue; + } + $months = self::PERIOD_MONTHS[$period] ?? null; + if ($months === null) { + continue; + } + if ($months > $bestMonths) { + $bestMonths = $months; + $best = $period; + } + } + + return $bestMonths > 0 ? $best : 'one_year'; + } + + public static function normalizePeriod(mixed $period): string + { + if (is_string($period) && in_array($period, self::VALID_PERIODS, true)) { + return $period; + } + + return 'one_year'; + } + + /** + * If the process retention period is not allowed for the current tier, set it to the + * longest period allowed for that tier, refresh retention_updated_at, clear retention_updated_by + * (so the UI shows the default retention message), and record when to show the admin notice (24h). + * + * @param list|null $tierAllowedPeriods When null, resolved from config; when set (e.g. from a + * batch command), avoids re-reading tier options per process. + * @return bool True when the process was updated. + */ + public static function clampProcessRetentionToCurrentTier(Process $process, ?array $tierAllowedPeriods = null): bool + { + $allowed = $tierAllowedPeriods ?? self::allowedPeriodsForCurrentTier(); + $current = self::normalizePeriod($process->properties['retention_period'] ?? null); + + if (in_array($current, $allowed, true)) { + return false; + } + + $maxPeriod = self::longestAllowedPeriod($allowed); + $properties = $process->properties ?? []; + $properties['retention_period'] = $maxPeriod; + $properties['retention_updated_at'] = now()->toIso8601String(); + unset($properties['retention_updated_by']); + unset($properties[self::NOTICE_PROPERTY_KEY]); + $properties[self::NOTICE_AT_PROPERTY_KEY] = now()->toIso8601String(); + $process->properties = $properties; + $process->saveQuietly(); + + return true; + } +} diff --git a/config/app.php b/config/app.php index 229bca462c..3774cf8f4a 100644 --- a/config/app.php +++ b/config/app.php @@ -310,7 +310,7 @@ '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' => trim((string) env('CASE_RETENTION_TIER', '1'), " \t\n\r\0\x0B\"'"), 'case_retention_tier_options' => [ '1' => ['six_months', 'one_year'], '2' => ['six_months', 'one_year', 'three_years'], diff --git a/resources/js/processes/components/ProcessMixin.js b/resources/js/processes/components/ProcessMixin.js index 51f75ab538..41e0ccad0c 100644 --- a/resources/js/processes/components/ProcessMixin.js +++ b/resources/js/processes/components/ProcessMixin.js @@ -123,6 +123,13 @@ export default { if (process.warnings) { process.warningMessages.push(this.$t("BPMN validation issues. Request cannot be started.")); } + if (process.case_retention_tier_adjustment_notice) { + process.warningMessages.push( + this.$t( + "Case retention was automatically shortened to match your subscription tier. The new retention period applies immediately.", + ), + ); + } return process; }); return data; diff --git a/resources/lang/en.json b/resources/lang/en.json index e580e5b49b..e2b44410a7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1607,6 +1607,7 @@ "Process is missing end event": "Process is missing end event", "Process is missing start event": "Process is missing start event", "Process Launchpad": "Process Launchpad", + "Case retention was automatically shortened to match your subscription tier. The new retention period applies immediately.": "Case retention was automatically shortened to match your subscription tier. The new retention period applies immediately.", "Process Manager not configured.": "Process Manager not configured.", "Process Manager": "Process Manager", "Process Owner": "Process Owner",