Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions ProcessMaker/Console/Commands/EvaluateCaseRetention.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use ProcessMaker\Jobs\EvaluateProcessRetentionJob;
use ProcessMaker\Models\Process;
use ProcessMaker\Models\ProcessCategory;
use ProcessMaker\Services\CaseRetentionTierService;

class EvaluateCaseRetention extends Command
{
Expand Down Expand Up @@ -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');
Expand All @@ -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++;
}
});
Expand Down
20 changes: 19 additions & 1 deletion ProcessMaker/Http/Controllers/Api/ProcessController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions ProcessMaker/Http/Resources/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 15 additions & 3 deletions ProcessMaker/Jobs/EvaluateProcessRetentionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|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,
) {
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
129 changes: 129 additions & 0 deletions ProcessMaker/Services/CaseRetentionTierService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace ProcessMaker\Services;

use Carbon\Carbon;
use ProcessMaker\Models\Process;

class CaseRetentionTierService
{
/**
* @deprecated Stored only for backward compatibility; use {@see self::NOTICE_AT_PROPERTY_KEY}.
*/
public const NOTICE_PROPERTY_KEY = 'case_retention_tier_adjustment_notice';

public const NOTICE_AT_PROPERTY_KEY = 'case_retention_tier_adjustment_notice_at';

public const NOTICE_DURATION_HOURS = 24;

private const VALID_PERIODS = ['six_months', 'one_year', 'three_years', 'five_years'];

/** @var array<string, int> */
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<string>
*/
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<string> $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<string>|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;
}
}
2 changes: 1 addition & 1 deletion config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
7 changes: 7 additions & 0 deletions resources/js/processes/components/ProcessMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading