From b936593c57206a9fe25c270718484f6905bb38b4 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:20:15 -0700 Subject: [PATCH 1/4] Support retention downgrades: Add case retention tier service and integration Introduce CaseRetentionTierService to manage tier-driven case retention rules and notices. Integrate the service into ProcessController to expose an admin-only adjustment notice flag, clear notice properties when an admin changes retention_period, and preserve notice keys when restoring process properties. Update EvaluateProcessRetentionJob to clamp process retention to the current tier (logging and refreshing the model when a clamp occurs). The service provides allowed-period lookup, period normalization, a 24-hour admin notice window, and a clamp operation that updates retention_period, retention_updated_at, clears retention_updated_by, and records when to show the notice. --- .../Controllers/Api/ProcessController.php | 20 +++- .../Jobs/EvaluateProcessRetentionJob.php | 9 ++ .../Services/CaseRetentionTierService.php | 93 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 ProcessMaker/Services/CaseRetentionTierService.php 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/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index e5b14a267d..230938d758 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -15,6 +15,7 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\TaskDraft; +use ProcessMaker\Services\CaseRetentionTierService; class EvaluateProcessRetentionJob implements ShouldQueue { @@ -75,6 +76,14 @@ public function handle(): void return; } + if (CaseRetentionTierService::clampProcessRetentionToCurrentTier($process)) { + 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..21295e9471 --- /dev/null +++ b/ProcessMaker/Services/CaseRetentionTierService.php @@ -0,0 +1,93 @@ +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']; + } + + 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). + * + * @return bool True when the process was updated. + */ + public static function clampProcessRetentionToCurrentTier(Process $process): bool + { + $allowed = self::allowedPeriodsForCurrentTier(); + $current = self::normalizePeriod($process->properties['retention_period'] ?? null); + + if (in_array($current, $allowed, true)) { + return false; + } + + $maxPeriod = $allowed[array_key_last($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; + } +} From e833af28cee519ba101ae192ab98cef51793b092 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:10:37 -0700 Subject: [PATCH 2/4] Add retention-shortening message to en.json Insert an English translation entry informing users that case retention was automatically shortened to match their subscription tier and that the new retention period applies immediately. --- config/app.php | 2 +- resources/lang/en.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/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", From 0d3e40c69fe4ffb9a3fc4a7b4311539aa6ab86d4 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:15:33 -0700 Subject: [PATCH 3/4] Use tier-allowed periods when evaluating retention Resolve allowed retention periods once in the command and pass them to the queued job to avoid re-resolving per process. Add PERIOD_MONTHS and longestAllowedPeriod() to determine the longest period by duration (not array order). Update EvaluateProcessRetentionJob to accept optional tierAllowedPeriods and clampProcessRetentionToCurrentTier() to use the provided list when present; clamp now sets the retention to the longest allowed period. Minor docblock and logging updates. --- .../Commands/EvaluateCaseRetention.php | 7 +++- .../Jobs/EvaluateProcessRetentionJob.php | 11 +++-- .../Services/CaseRetentionTierService.php | 42 +++++++++++++++++-- 3 files changed, 51 insertions(+), 9 deletions(-) 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/Jobs/EvaluateProcessRetentionJob.php b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php index 230938d758..5ca45cfec0 100644 --- a/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php +++ b/ProcessMaker/Jobs/EvaluateProcessRetentionJob.php @@ -22,10 +22,13 @@ 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, + ) { } /** @@ -76,7 +79,7 @@ public function handle(): void return; } - if (CaseRetentionTierService::clampProcessRetentionToCurrentTier($process)) { + 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, diff --git a/ProcessMaker/Services/CaseRetentionTierService.php b/ProcessMaker/Services/CaseRetentionTierService.php index 21295e9471..85bd53d7b5 100644 --- a/ProcessMaker/Services/CaseRetentionTierService.php +++ b/ProcessMaker/Services/CaseRetentionTierService.php @@ -20,6 +20,14 @@ class CaseRetentionTierService private const VALID_PERIODS = ['six_months', 'one_year', 'three_years', 'five_years']; + /** @var array */ + 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). */ @@ -53,6 +61,32 @@ public static function allowedPeriodsForCurrentTier(): array 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)) { @@ -67,18 +101,20 @@ public static function normalizePeriod(mixed $period): string * 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): bool + public static function clampProcessRetentionToCurrentTier(Process $process, ?array $tierAllowedPeriods = null): bool { - $allowed = self::allowedPeriodsForCurrentTier(); + $allowed = $tierAllowedPeriods ?? self::allowedPeriodsForCurrentTier(); $current = self::normalizePeriod($process->properties['retention_period'] ?? null); if (in_array($current, $allowed, true)) { return false; } - $maxPeriod = $allowed[array_key_last($allowed)]; + $maxPeriod = self::longestAllowedPeriod($allowed); $properties = $process->properties ?? []; $properties['retention_period'] = $maxPeriod; $properties['retention_updated_at'] = now()->toIso8601String(); From 91da6b12cb4ae48e0699e8cd1eb6f764407ec4b1 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:00:59 -0700 Subject: [PATCH 4/4] Expose retention tier notice and show warning Add a boolean case_retention_tier_adjustment_notice field to the Process API resource (defaults to false) so clients can detect when case retention was automatically shortened to match the subscription tier. Update the frontend ProcessMixin to append a localized warning message to process.warningMessages when this flag is true, informing users the new retention period applies immediately. --- ProcessMaker/Http/Resources/Process.php | 1 + resources/js/processes/components/ProcessMixin.js | 7 +++++++ 2 files changed, 8 insertions(+) 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/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;