diff --git a/.env.example b/.env.example index 702c98dee6..1a6fee08bd 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ OPEN_AI_NLQ_TO_PMQL_ENABLED=true OPEN_AI_PROCESS_TRANSLATIONS_ENABLED=true OPEN_AI_SECRET="sk-O2D..." AI_MICROSERVICE_HOST="http://localhost:8010" +AI_MICROSERVICE_HOST_WS="wss://localhost:8010" PROCESS_REQUEST_ERRORS_RATE_LIMIT=1 PROCESS_REQUEST_ERRORS_RATE_LIMIT_DURATION=86400 CUSTOM_EXECUTORS=false diff --git a/.gitignore b/.gitignore index f375b4a8bc..8d89feb4b6 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ devhub/pm-font/dist test-db-snapshot.db snapshot_*.db storage/transitions +.envrc \ No newline at end of file diff --git a/ProcessMaker/Application.php b/ProcessMaker/Application.php index b459358dd3..f9edc37e11 100644 --- a/ProcessMaker/Application.php +++ b/ProcessMaker/Application.php @@ -3,6 +3,7 @@ namespace ProcessMaker; use Igaster\LaravelTheme\Facades\Theme; +use Illuminate\Container\Container; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application as IlluminateApplication; use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; @@ -12,8 +13,8 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; +use ProcessMaker\Console\Kernel; use ProcessMaker\Multitenancy\Tenant; -use ProcessMaker\Multitenancy\TenantBootstrapper; /** * Class Application. @@ -101,15 +102,4 @@ public function registerConfiguredProviders() parent::registerConfiguredProviders(); } - - public function bootstrapWith(array $bootstrappers) - { - // Insert TenantBootstrapper after LoadEnvironmentVariables - if ($bootstrappers[0] !== LoadEnvironmentVariables::class) { - throw new \Exception('LoadEnvironmentVariables is not the first bootstrapper. Did a laravel upgrade change this?'); - } - array_splice($bootstrappers, 1, 0, [TenantBootstrapper::class]); - - return parent::bootstrapWith($bootstrappers); - } } diff --git a/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php b/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php index c8a821fa14..fbc970c83f 100644 --- a/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php +++ b/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php @@ -6,6 +6,8 @@ use ProcessMaker\Exception\ThereIsNoProcessManagerAssignedException; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\User; use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; @@ -24,16 +26,62 @@ class ProcessManagerAssigned implements AssignmentRuleInterface * @param TokenInterface $token * @param Process $process * @param ProcessRequest $request - * @return int + * @return int|null * @throws ThereIsNoProcessManagerAssignedException */ public function getNextUser(ActivityInterface $task, TokenInterface $token, Process $process, ProcessRequest $request) { - $user_id = $request->processVersion->manager_id; + // review for multiple managers + $managers = $request->processVersion->manager_id; + $user_id = $this->getNextManagerAssigned($managers, $task, $request); if (!$user_id) { throw new ThereIsNoProcessManagerAssignedException($task); } return $user_id; } + + /** + * Get the round robin manager using a true round robin algorithm + * + * @param array $managers + * @param ActivityInterface $task + * @param ProcessRequest $request + * @return int|null + */ + private function getNextManagerAssigned($managers, $task, $request) + { + // Validate input + if (empty($managers) || !is_array($managers)) { + return null; + } + + // If only one manager, return it + if (count($managers) === 1) { + return $managers[0]; + } + + // get the last manager assigned to the task across all requests + $last = ProcessRequestToken::where('process_id', $request->process_id) + ->where('element_id', $task->getId()) + ->whereIn('user_id', $managers) + ->orderBy('created_at', 'desc') + ->first(); + + $user_id = $last ? $last->user_id : null; + + sort($managers); + + $key = array_search($user_id, $managers); + if ($key === false) { + // If no previous manager found, start with the first manager + $key = 0; + } else { + // Move to the next manager in the round-robin + $key = ($key + 1) % count($managers); + } + $user_id = $managers[$key]; + + return $user_id; + } } diff --git a/ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php b/ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php new file mode 100644 index 0000000000..c3f3f79151 --- /dev/null +++ b/ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php @@ -0,0 +1,49 @@ +clone()->chunkById(500, function ($rows) use ($stream) { + foreach ($rows as $row) { + $caseIds = $row->case_ids; + if (is_array($caseIds)) { + $caseIds = json_encode($caseIds); + } + + fputcsv($stream, [ + $row->id, + $row->process_id, + $caseIds, + $row->deleted_count, + $row->total_time_taken, + self::csvDateColumn($row->deleted_at), + self::csvDateColumn($row->created_at), + ]); + } + }); + } + + public static function csvDateColumn(mixed $value): string + { + if ($value === null || $value === '') { + return ''; + } + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string) $value; + } +} diff --git a/ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php b/ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php new file mode 100644 index 0000000000..e6f9d6627f --- /dev/null +++ b/ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php @@ -0,0 +1,39 @@ +getConnection()->getDriverName(); + + $query->where(function ($q) use ($like, $driver) { + $q->where('id', 'like', $like) + ->orWhere('process_id', 'like', $like) + ->orWhere('deleted_count', 'like', $like) + ->orWhere('total_time_taken', 'like', $like); + + if ($driver === 'pgsql') { + $q->orWhereRaw('case_ids::text ILIKE ?', [$like]); + } else { + $q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]); + } + }); + } +} diff --git a/ProcessMaker/Console/Commands/BuildScriptExecutors.php b/ProcessMaker/Console/Commands/BuildScriptExecutors.php index 6b745fcb9e..0545cc60ca 100644 --- a/ProcessMaker/Console/Commands/BuildScriptExecutors.php +++ b/ProcessMaker/Console/Commands/BuildScriptExecutors.php @@ -18,7 +18,11 @@ class BuildScriptExecutors extends Command * * @var string */ - protected $signature = 'processmaker:build-script-executor {lang} {user?} {--rebuild}'; + protected $signature = 'processmaker:build-script-executor + {lang : The ID or language of the script executor} + {user? : The user ID to send the broadcast event to} + {--rebuild : Rebuild the docker image} + {--build-args= : The build arguments for the docker build command}'; /** * The console command description. @@ -156,9 +160,17 @@ public function buildExecutor() $this->info('Building the docker executor'); $image = $scriptExecutor->dockerImageName(); + $cacheArg = $this->option('rebuild') ? '--no-cache ' : ''; $command = Docker::command() . - " build --build-arg SDK_DIR=./sdk -t {$image} -f {$packagePath}/Dockerfile.custom {$packagePath}"; + " build {$cacheArg}--build-arg SDK_DIR=./sdk -t {$image} -f {$packagePath}/Dockerfile.custom {$packagePath}"; + $buildArgs = $this->getBuildArgs(); + + foreach ($buildArgs as $buildArg) { + $command .= ' ' . $buildArg; + } + + $this->info("Running command: $command"); $this->execCommand($command); $isNayra = $scriptExecutor->language === Base::NAYRA_LANG; @@ -167,6 +179,29 @@ public function buildExecutor() } } + /** + * Get the build arguments for the docker build command. + * + * @return array + * - '--build-arg =' + */ + public function getBuildArgs(): array + { + $args = $this->option('build-args'); + + if ($args) { + $buildArgs = []; + + foreach (explode(',', $args) as $arg) { + $buildArgs[] = '--build-arg ' . $arg; + } + + return $buildArgs; + } + + return []; + } + public function getDockerfileContent(ScriptExecutor $scriptExecutor): string { $lang = $scriptExecutor->language; diff --git a/ProcessMaker/Console/Commands/CreateDataLakeViews.php b/ProcessMaker/Console/Commands/CreateDataLakeViews.php index 10d7b60969..88be9df86a 100644 --- a/ProcessMaker/Console/Commands/CreateDataLakeViews.php +++ b/ProcessMaker/Console/Commands/CreateDataLakeViews.php @@ -181,9 +181,10 @@ protected function getTableColumns(string $tableName): array */ protected function getTables(): array { + $database = \DB::connection()->getDatabaseName(); $tables = array_map(function ($item) { return $item['name']; - }, Schema::getTables()); + }, Schema::getTables($database)); return $tables; } @@ -193,9 +194,10 @@ protected function getTables(): array */ protected function getViews(): array { + $database = \DB::connection()->getDatabaseName(); $views = array_map(function ($item) { return $item['name']; - }, Schema::getViews()); + }, Schema::getViews($database)); return $views; } diff --git a/ProcessMaker/Console/Commands/CreateTestDBs.php b/ProcessMaker/Console/Commands/CreateTestDBs.php deleted file mode 100644 index 5cdd54383c..0000000000 --- a/ProcessMaker/Console/Commands/CreateTestDBs.php +++ /dev/null @@ -1,66 +0,0 @@ - $file"; - (new Process($cmd))->mustRun(); - - foreach (range(1, $processes) as $process) { - $database = "test_$process"; - $this->info("Creating database $database"); - - $cmd = "mysql $dbConnectionArgs -e 'DROP DATABASE IF EXISTS $database'"; - (new Process($cmd))->mustRun(); - - $cmd = "mysql $dbConnectionArgs -e 'CREATE DATABASE $database'"; - (new Process($cmd))->mustRun(); - - $cmd = "mysql $dbConnectionArgs $database < $file"; - (new Process($cmd))->mustRun(); - } - } -} diff --git a/ProcessMaker/Console/Commands/EvaluateCaseRetention.php b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php new file mode 100644 index 0000000000..6c9a615370 --- /dev/null +++ b/ProcessMaker/Console/Commands/EvaluateCaseRetention.php @@ -0,0 +1,77 @@ +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 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'); + + // 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, $tierAllowedPeriods) { + foreach ($processes as $process) { + dispatch(new EvaluateProcessRetentionJob($process->id, $tierAllowedPeriods)); + $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/Commands/IndexedSearchEnable.php b/ProcessMaker/Console/Commands/IndexedSearchEnable.php index b71f1f6df6..35cdbaf9c0 100644 --- a/ProcessMaker/Console/Commands/IndexedSearchEnable.php +++ b/ProcessMaker/Console/Commands/IndexedSearchEnable.php @@ -123,9 +123,8 @@ private function setConfig($driver, $url = null, $prefix = null) $env .= "\n\nSCOUT_DRIVER={$driver}"; $env .= "\nELASTIC_HOST={$url}"; break; - case 'sqlite': - $driver = 'tntsearch'; - $env .= "\n\nSCOUT_DRIVER={$driver}"; + default: + throw new \Exception('Only Elasticsearch is supported for indexed search.'); break; } diff --git a/ProcessMaker/Console/Commands/Install.php b/ProcessMaker/Console/Commands/Install.php index ec719c993b..85c5202ada 100644 --- a/ProcessMaker/Console/Commands/Install.php +++ b/ProcessMaker/Console/Commands/Install.php @@ -52,7 +52,7 @@ class Install extends Command {--data-username= : The data database username} {--data-password= : The data database password} {--data-schema= : The data database schema (if pgsql)} - {--redis-client=predis : The Redis client (predis or phpredis)} + {--redis-client=phpredis : The Redis client (predis or phpredis)} {--redis-host= : The Redis host, default is 127.0.0.1} {--redis-prefix= : The prefix to be appended to Redis entries} {--horizon-prefix=horizon: : The prefix to be appended to Horizon queue entries} diff --git a/ProcessMaker/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index 6048bc68bc..43635aea58 100644 --- a/ProcessMaker/Console/Commands/TenantsCreate.php +++ b/ProcessMaker/Console/Commands/TenantsCreate.php @@ -198,7 +198,9 @@ public function handle() $this->line('- Run migrations and seed the database'); $this->line('- Run the install command for each package'); $this->line('- Run artisan upgrade'); - $this->line('- Install passport by calling passport:install'); + $this->line('- Install passport by calling passport:install (create the default clients'); + $this->line('- Reset the admin password with auth:set-password'); + $this->line('- Run processmaker:initialize-script-microservice'); $this->info("For example, `TENANT={$tenant->id} php artisan migrate:fresh --seed`"); } } diff --git a/ProcessMaker/Console/Commands/TenantsList.php b/ProcessMaker/Console/Commands/TenantsList.php index 72b24b599d..0971536155 100644 --- a/ProcessMaker/Console/Commands/TenantsList.php +++ b/ProcessMaker/Console/Commands/TenantsList.php @@ -15,7 +15,7 @@ class TenantsList extends Command * * @var string */ - protected $signature = 'tenants:list {--ids : Only output the ids}'; + protected $signature = 'tenants:list {--ids : Only output the ids} {--json : Output the tenants as JSON}'; /** * The console command description. @@ -40,6 +40,12 @@ public function handle() return; } + if ($this->option('json')) { + $this->line(json_encode($tenants->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return; + } + $formattedTenants = $tenants->map(function ($tenant) { $config = $tenant->config; diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index 36ff3b01ac..46cd1e224c 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -19,7 +19,7 @@ class TenantsVerify extends Command * * @var string */ - protected $signature = 'tenants:verify'; + protected $signature = 'tenants:verify {--json : Output the results as JSON}'; /** * The console command description. @@ -33,20 +33,29 @@ class TenantsVerify extends Command * * @return int */ + private $jsonData = []; + public function handle() { + if (!config('app.multitenancy')) { + $this->info('Multitenancy is disabled'); + + return; + } + + $errors = []; $currentTenant = null; if (app()->has('currentTenant')) { $currentTenant = app('currentTenant'); } - if (config('app.multitenancy') && !$currentTenant) { + if (!$currentTenant) { $this->error('Multitenancy enabled but no current tenant found.'); return; } - $this->info('Current Tenant ID: ' . ($currentTenant?->id ?? 'NONE')); + \Log::warning('TenantsVerify: Current Tenant ID: ' . ($currentTenant?->id ?? 'NONE')); $paths = [ ['Storage Path', storage_path()], @@ -55,32 +64,44 @@ public function handle() ]; // Display paths in a nice table - $this->table(['Path', 'Value'], $paths); + $this->infoTable(['Path', 'Value'], $paths); $configs = [ - 'app.key', - 'app.url', - 'app.instance', - 'cache.prefix', - 'database.redis.options.prefix', - 'cache.stores.cache_settings.prefix', - 'script-runner-microservice.callback', - 'database.connections.processmaker.database', - 'logging.channels.daily.path', - 'filesystems.disks.public.root', - 'filesystems.disks.local.root', - 'filesystems.disks.lang.root', + 'app.key' => null, + 'app.url' => null, + 'app.instance' => null, + 'cache.prefix' => 'tenant_{tenant_id}:', + 'database.redis.options.prefix' => null, + 'cache.stores.cache_settings.prefix' => 'tenant_{tenant_id}:settings', + 'script-runner-microservice.callback' => null, + 'database.connections.processmaker.database' => null, + 'logging.channels.daily.path' => base_path() . '/storage/tenant_{tenant_id}/logs/processmaker.log', + 'filesystems.disks.public.root' => base_path() . '/storage/tenant_{tenant_id}/app/public', + 'filesystems.disks.local.root' => base_path() . '/storage/tenant_{tenant_id}/app', + 'filesystems.disks.lang.root' => base_path() . '/resources/lang/tenant_{tenant_id}', ]; - $configs = array_map(function ($config) { + $configs = array_map(function ($config) use ($configs, $currentTenant, &$errors) { + $ok = ''; + if ($configs[$config] !== null) { + $expected = str_replace('{tenant_id}', $currentTenant->id, $configs[$config]); + if (config($config) === $expected) { + $ok = '✓'; + } else { + $ok = '✗'; + $errors[] = 'Expected: ' . $expected . ' != Actual: ' . config($config); + } + } + return [ $config, config($config), + $ok, ]; - }, $configs); + }, array_keys($configs)); // Display configs in a nice table - $this->table(['Config', 'Value'], $configs); + $this->infoTable(['Config', 'Value', 'OK'], $configs); $env = EnvironmentVariable::first(); if (!$env) { @@ -102,10 +123,62 @@ public function handle() ['Tenant Config Is Cached', File::exists(app()->getCachedConfigPath()) ? 'Yes' : 'No'], ['First username (database check)', User::first()?->username ?? 'No users found'], ['Decrypted check', substr($decrypted, 0, 50)], - ['Original App URL (landlord)', $currentTenant?->getOriginalValue('APP_URL') ?? config('app.url')], + // ['Original App URL (landlord)', $currentTenant?->getOriginalValue('APP_URL') ?? config('app.url')], + ['config("app.url")', config('app.url')], + ['getenv("APP_URL")', getenv('APP_URL')], + ['env("APP_URL")', env('APP_URL')], + ['$_SERVER["APP_URL"]', $_SERVER['APP_URL'] ?? 'NOT SET'], + ['$_ENV["APP_URL"]', $_ENV['APP_URL'] ?? 'NOT SET'], + ['Current PID', getmypid()], ]; // Display other in a nice table - $this->table(['Other', 'Value'], $other); + $this->infoTable(['Other', 'Value'], $other); + + $checkUrls = [ + 'config("app.url")' => config('app.url'), + 'getenv("APP_URL")' => getenv('APP_URL'), + 'env("APP_URL")' => env('APP_URL'), + '$_SERVER["APP_URL"]' => $_SERVER['APP_URL'] ?? 'NOT SET', + '$_ENV["APP_URL"]' => $_ENV['APP_URL'] ?? 'NOT SET', + ]; + + foreach ($checkUrls as $key => $value) { + if ($value !== $currentTenant?->config['app.url']) { + $errors[] = 'Expected: ' . $key . ' to be ' . $currentTenant?->config['app.url'] . ' but got ' . $value; + } + } + + $this->finish($errors); + } + + private function finish($errors) + { + if (count($errors) > 0) { + $this->error('Errors found'); + } else { + $this->info('No errors found'); + } + + if ($this->option('json')) { + $this->jsonData['Errors'] = $errors; + $this->line(json_encode($this->jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + } + + private function infoTable($headers, $rows) + { + if ($this->option('json')) { + $section = []; + foreach ($rows as $row) { + $section[$row[0]] = $row[1]; + } + $this->jsonData[$headers[0]] = $section; + } else { + foreach ($rows as $row) { + \Log::warning($row[0] . ': ' . $row[1]); + } + $this->table($headers, $rows); + } } } diff --git a/ProcessMaker/Console/Kernel.php b/ProcessMaker/Console/Kernel.php index d5f52d15e1..03fc408949 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -24,7 +24,8 @@ protected function schedule(Schedule $schedule) { $schedule->command('bpmn:timer') ->everyMinute() - ->onOneServer(); + ->onOneServer() + ->withoutOverlapping(config('app.scheduler.bpmn_timer_overlap_minutes', 5)); $schedule->command('processmaker:sync-recommendations --queue') ->daily() @@ -88,6 +89,16 @@ protected function schedule(Schedule $schedule) $schedule->command('metrics:clear')->cron("*/{$clearInterval} * * * *"); 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/Contracts/ConditionalRedirectServiceInterface.php b/ProcessMaker/Contracts/ConditionalRedirectServiceInterface.php new file mode 100644 index 0000000000..e46e390096 --- /dev/null +++ b/ProcessMaker/Contracts/ConditionalRedirectServiceInterface.php @@ -0,0 +1,32 @@ +processRequest; + } + + /** + * Return the process request token. + * * @return \ProcessMaker\Models\ProcessRequestToken */ public function getProcessRequestToken() diff --git a/ProcessMaker/Events/CaseDeleted.php b/ProcessMaker/Events/CaseDeleted.php new file mode 100644 index 0000000000..8c3abb6049 --- /dev/null +++ b/ProcessMaker/Events/CaseDeleted.php @@ -0,0 +1,65 @@ +caseNumber = $caseNumber; + $this->caseTitle = $caseTitle; + } + + /** + * Get specific data related to the event + * + * @return array + */ + public function getData(): array + { + return [ + 'name' => $this->caseTitle, + 'case_number' => $this->caseNumber, + 'deleted_at' => Carbon::now(), + ]; + } + + /** + * Get specific data related to the event + * + * @return array + */ + public function getChanges(): array + { + return [ + 'case_number' => $this->caseNumber, + ]; + } + + /** + * Get the Event name + * + * @return string + */ + public function getEventName(): string + { + return 'CaseDeleted'; + } +} diff --git a/ProcessMaker/Events/GroupMembershipChanged.php b/ProcessMaker/Events/GroupMembershipChanged.php new file mode 100644 index 0000000000..f187fc975b --- /dev/null +++ b/ProcessMaker/Events/GroupMembershipChanged.php @@ -0,0 +1,88 @@ +group = $group; + $this->parentGroup = $parentGroup; + $this->action = $action; + $this->groupMember = $groupMember; + } + + /** + * Get the group that was affected + */ + public function getGroup(): ?Group + { + return $this->group; + } + + /** + * Get the parent group (if any) + */ + public function getParentGroup(): ?Group + { + return $this->parentGroup; + } + + /** + * Get the action performed + */ + public function getAction(): string + { + return $this->action; + } + + /** + * Get the group member record + */ + public function getGroupMember(): ?GroupMember + { + return $this->groupMember; + } + + /** + * Check if this is a removal action + */ + public function isRemoval(): bool + { + return $this->action === 'removed'; + } + + /** + * Check if this is an addition action + */ + public function isAddition(): bool + { + return $this->action === 'added'; + } + + /** + * Check if this is an update action + */ + public function isUpdate(): bool + { + return $this->action === 'updated'; + } +} diff --git a/ProcessMaker/Events/PermissionUpdated.php b/ProcessMaker/Events/PermissionUpdated.php index 08d6f551ef..ea4f077544 100644 --- a/ProcessMaker/Events/PermissionUpdated.php +++ b/ProcessMaker/Events/PermissionUpdated.php @@ -148,4 +148,24 @@ public function getEventName(): string { return 'PermissionUpdated'; } + + /** + * Get the user ID + * + * @return string|null + */ + public function getUserId(): ?string + { + return $this->userId; + } + + /** + * Get the group ID + * + * @return string|null + */ + public function getGroupId(): ?string + { + return $this->groupId; + } } diff --git a/ProcessMaker/Events/ProcessUpdated.php b/ProcessMaker/Events/ProcessUpdated.php index 01d253303b..a5e91245f3 100644 --- a/ProcessMaker/Events/ProcessUpdated.php +++ b/ProcessMaker/Events/ProcessUpdated.php @@ -26,6 +26,8 @@ class ProcessUpdated implements ShouldBroadcastNow public $activeTokens; + public $elementDestination; + /** * Create a new event instance. * @@ -41,6 +43,7 @@ public function __construct(ProcessRequest $processRequest, $event, TokenInterfa if ($token) { $this->tokenId = $token->getId(); $this->elementType = $token->element_type; + $this->elementDestination = $token->elementDestination; } } diff --git a/ProcessMaker/Exception/MultitenancyAccessedLandlord.php b/ProcessMaker/Exception/MultitenancyAccessedLandlord.php index d2bb0654ed..cfeb78fd3b 100644 --- a/ProcessMaker/Exception/MultitenancyAccessedLandlord.php +++ b/ProcessMaker/Exception/MultitenancyAccessedLandlord.php @@ -5,11 +5,21 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; +use ProcessMaker\Facades\Metrics; class MultitenancyAccessedLandlord extends Exception { public function render(Request $request): Response { + // If we're trying to access the /metrics route, collect landlord metrics and render them + if ($request->path() === 'metrics') { + Metrics::collectQueueMetrics(); + + return response(Metrics::renderMetrics(), 200, [ + 'Content-Type' => 'text/plain; version=0.0.4', + ]); + } + return response()->view('multitenancy.landlord-landing-page'); } diff --git a/ProcessMaker/Helpers/DataTypeHelper.php b/ProcessMaker/Helpers/DataTypeHelper.php index 58ef6897ea..7cd48a5293 100644 --- a/ProcessMaker/Helpers/DataTypeHelper.php +++ b/ProcessMaker/Helpers/DataTypeHelper.php @@ -10,6 +10,9 @@ private static function isDate($value) { if (is_string($value)) { if (strlen($value) > 5) { + if (!preg_match('/\d{4}-\d{2}-\d{2}/', $value)) { + return false; + } try { $parsed = Carbon::parse($value); if ($parsed->isMidnight()) { diff --git a/ProcessMaker/Helpers/ScreenTemplateHelper.php b/ProcessMaker/Helpers/ScreenTemplateHelper.php index 67a3684c3f..3659eb71de 100644 --- a/ProcessMaker/Helpers/ScreenTemplateHelper.php +++ b/ProcessMaker/Helpers/ScreenTemplateHelper.php @@ -4,6 +4,32 @@ class ScreenTemplateHelper { + private const RENDERABLE_STRING_FIELDS = [ + 'ariaLabel', + 'content', + 'fieldValue', + 'helper', + 'label', + 'loadingLabel', + 'placeholder', + ]; + + /** + * Remove serialized Vue component definitions from screen config. + * + * Screen templates can contain old inspector metadata where inspector.type + * is a serialized Vue component object. That data is not needed at runtime + * and can reach renderer paths that expect Mustache templates to be strings. + */ + public static function sanitizeScreenConfig(mixed $config): array + { + if (!is_array($config)) { + return []; + } + + return self::sanitizeConfigValue($config); + } + /** * Remove screen components from the configuration based on the provided components. * @@ -402,4 +428,59 @@ public static function generateCss($cssArray) return $cssString; } + + private static function sanitizeConfigValue(mixed $value, ?string $key = null): mixed + { + if ($key === 'validation' && is_array($value) && $value === []) { + $sanitized = null; + } elseif (in_array($key, self::RENDERABLE_STRING_FIELDS, true)) { + $sanitized = self::sanitizeRenderableString($value); + } elseif (!is_array($value)) { + $sanitized = $value; + } elseif (array_is_list($value)) { + $sanitized = array_map(fn ($item) => self::sanitizeConfigValue($item), $value); + } else { + $sanitized = []; + foreach ($value as $childKey => $childValue) { + if ($childKey === 'inspector' && is_array($childValue)) { + $sanitized[$childKey] = array_map( + fn ($item) => self::sanitizeInspectorItem($item), + $childValue + ); + continue; + } + + $sanitized[$childKey] = self::sanitizeConfigValue($childValue, (string) $childKey); + } + } + + return $sanitized; + } + + private static function sanitizeInspectorItem(mixed $item): mixed + { + if (!is_array($item)) { + return $item; + } + + $sanitized = []; + foreach ($item as $key => $value) { + if ($key === 'type' && is_array($value)) { + continue; + } + + $sanitized[$key] = self::sanitizeConfigValue($value, (string) $key); + } + + return $sanitized; + } + + private static function sanitizeRenderableString(mixed $value): string + { + if ($value === null || is_array($value)) { + return ''; + } + + return is_string($value) ? $value : (string) $value; + } } diff --git a/ProcessMaker/Http/Controllers/Admin/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Admin/CasesRetentionController.php new file mode 100644 index 0000000000..4a686c203f --- /dev/null +++ b/ProcessMaker/Http/Controllers/Admin/CasesRetentionController.php @@ -0,0 +1,15 @@ +input('devlink_id'); $redirectUri = $request->input('redirect_uri'); - $client = Client::where([ + // We can't re-use a client because the secret is hashed. + Client::where([ 'name' => 'devlink', 'redirect' => $redirectUri, - ])->first(); + ]) + ->get() + ->each(function ($c) { + $c->delete(); + }); - if (!$client) { - $clientRepository = app('Laravel\Passport\ClientRepository'); - $client = $clientRepository->create(null, 'devlink', $redirectUri); - } + $clientRepository = app('Laravel\Passport\ClientRepository'); + $client = $clientRepository->createAuthorizationCodeGrantClient('devlink', [$redirectUri]); + $plainSecret = $client->plainSecret; $query = http_build_query([ 'devlink_id' => $devLinkId, 'client_id' => $client->id, - 'client_secret' => $client->secret, + 'client_secret' => $plainSecret, ]); return redirect($redirectUri . '?' . $query); diff --git a/ProcessMaker/Http/Controllers/Admin/LogsController.php b/ProcessMaker/Http/Controllers/Admin/LogsController.php new file mode 100644 index 0000000000..37ef170c8c --- /dev/null +++ b/ProcessMaker/Http/Controllers/Admin/LogsController.php @@ -0,0 +1,20 @@ +user()->is_administrator) { - // Register the Event - QueueManagementAccessed::dispatch(); + if (!auth()->user()->is_administrator) { + throw new AuthorizationException(); + } - return view('admin.queues.index'); + if (config('app.multitenancy')) { + if (!TenantQueueServiceProvider::allowAllTenats()) { + // Its multitenancy and they don't have access to all tenants so + // redirect to the tenant-filtered queue management page. + // Otherwise, show the horizon queue manager. + return redirect()->route('tenant-queue.index'); + } } - throw new AuthorizationException(); + // Register the Event + QueueManagementAccessed::dispatch(); + + return view('admin.queues.index'); } } diff --git a/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php b/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php index 5635224eda..2d8198434c 100644 --- a/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php +++ b/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php @@ -14,29 +14,12 @@ class TenantQueueController extends Controller { - /** - * Constructor to check if tenant tracking is enabled. - */ - public function __construct() - { - // Check if tenant job tracking is enabled - $enabled = config('queue.tenant_tracking_enabled', false); - - if (!$enabled) { - if (!app()->runningInConsole()) { - abort(404, 'Tenant queue tracking is disabled'); - } - } - } - /** * Show the tenant jobs dashboard. */ public function index() { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); return view('admin.tenant-queues.index'); } @@ -46,12 +29,16 @@ public function index() */ public function getTenants(): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantsWithJobs = TenantQueueServiceProvider::getTenantsWithJobs(); + if (!TenantQueueServiceProvider::allowAllTenats()) { + $tenantsWithJobs = array_filter($tenantsWithJobs, function ($tenantData) { + return (int) $tenantData['id'] === app('currentTenant')?->id; + }); + } + // Enrich with tenant information $tenants = []; foreach ($tenantsWithJobs as $tenantData) { @@ -74,9 +61,7 @@ public function getTenants(): JsonResponse */ public function getTenantJobs(Request $request, string $tenantId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $status = $request->get('status'); $limit = min((int) $request->get('limit', 50), 100); // Max 100 jobs @@ -112,12 +97,16 @@ public function getTenantStats(string $tenantId): JsonResponse */ public function getOverallStats(): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantsWithJobs = TenantQueueServiceProvider::getTenantsWithJobs(); + if (!TenantQueueServiceProvider::allowAllTenats()) { + $tenantsWithJobs = array_filter($tenantsWithJobs, function ($tenantData) { + return (int) $tenantData['id'] === app('currentTenant')?->id; + }); + } + $overallStats = [ 'total_tenants' => count($tenantsWithJobs), 'total_jobs' => 0, @@ -144,9 +133,7 @@ public function getOverallStats(): JsonResponse */ public function getJobDetails(string $tenantId, string $jobId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantKey = "tenant_jobs:{$tenantId}:{$jobId}"; $jobData = Redis::hgetall($tenantKey); @@ -180,9 +167,7 @@ public function getJobDetails(string $tenantId, string $jobId): JsonResponse */ public function clearTenantJobs(string $tenantId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); try { $pattern = "tenant_jobs:{$tenantId}:*"; @@ -209,4 +194,25 @@ public function clearTenantJobs(string $tenantId): JsonResponse return response()->json(['error' => 'Failed to clear tenant job data'], 500); } } + + private function checkPermissions(): void + { + // Check if tenant job tracking is enabled + $enabled = TenantQueueServiceProvider::enabled(); + + if (!$enabled) { + throw new AuthorizationException('Tenant queue tracking is disabled'); + } + + if (!Auth::user()->is_administrator) { + throw new AuthorizationException(); + } + + // If the route binding has a tenant id, check if the user is allowed to access the tenant queue + if ($id = (int) request()->route('tenantId')) { + if (!TenantQueueServiceProvider::allowAllTenats() && $id !== app('currentTenant')?->id) { + throw new AuthorizationException(); + } + } + } } diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php new file mode 100644 index 0000000000..7dc266c2ea --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -0,0 +1,102 @@ +getRequestIds($caseNumber); + + if ($requestIds === []) { + abort(404); + } + + $caseTitle = $this->getCaseTitle($caseNumber); + $tokenIds = $this->getRequestTokenIds($requestIds); + + DB::transaction(function () use ($caseNumber, $requestIds, $tokenIds) { + $this->deleteInboxRuleLogs($tokenIds); + $this->deleteInboxRules($tokenIds); + $this->deleteProcessRequestLocks($requestIds, $tokenIds); + $this->deleteProcessAbeRequestTokens($requestIds, $tokenIds); + $this->deleteScheduledTasks($requestIds, $tokenIds); + $this->deleteEllucianEthosSyncTasks($tokenIds); + $draftIds = $this->getTaskDraftIds($tokenIds); + $this->deleteTaskDraftMedia($draftIds); + $this->deleteTaskDrafts($tokenIds); + $this->deleteComments($caseNumber, $requestIds, $tokenIds); + $this->deleteNotifications($requestIds); + $this->deleteRequestMedia($requestIds); + $this->deleteCaseNumbers($requestIds); + $this->deleteCasesStarted($caseNumber); + $this->deleteCasesParticipated($caseNumber); + $this->deleteProcessRequestTokens($requestIds); + $this->deleteProcessRequests($requestIds); + }); + + CaseDeleted::dispatch($caseNumber, $caseTitle); + + $this->dispatchSavedSearchRecount(); + } + + private function getRequestIds(string $caseNumber): array + { + return ProcessRequest::query() + ->where('case_number', $caseNumber) + ->pluck('id') + ->all(); + } + + private function getCaseTitle(string $caseNumber): string + { + $caseStarted = CaseStarted::query() + ->where('case_number', $caseNumber) + ->first(); + + if ($caseStarted) { + return $caseStarted->case_title ?? "Case #{$caseNumber}"; + } else { + // If CaseStarted doesn't exist, get case title from the first ProcessRequest + $firstRequest = ProcessRequest::query() + ->where('case_number', $caseNumber) + ->whereNull('parent_request_id') + ->first(); + + return $firstRequest?->case_title ?? "Case #{$caseNumber}"; + } + } + + private function getRequestTokenIds(array $requestIds): array + { + if ($requestIds === []) { + return []; + } + + return ProcessRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->pluck('id') + ->all(); + } + + private function getTaskDraftIds(array $tokenIds): array + { + if ($tokenIds === []) { + return []; + } + + return TaskDraft::query() + ->whereIn('task_id', $tokenIds) + ->pluck('id') + ->all(); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php new file mode 100644 index 0000000000..05bc9ebe00 --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php @@ -0,0 +1,260 @@ +whereIn('case_number', $caseNumbers) + ->delete(); + } else { + CaseStarted::query() + ->where('case_number', $caseNumbers) + ->delete(); + } + } + + private function deleteCasesParticipated(string | array $caseNumbers): void + { + 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 + { + if ($requestIds === []) { + return; + } + + CaseNumber::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + } + + private function deleteProcessRequests(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + ProcessRequest::query() + ->whereIn('id', $requestIds) + ->get() + ->each + ->delete(); + } + + private function deleteProcessRequestTokens(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + ProcessRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + } + + private function deleteProcessRequestLocks(array $requestIds, array $tokenIds): void + { + ProcessRequestLock::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ProcessRequestLock::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + } + + private function deleteProcessAbeRequestTokens(array $requestIds, array $tokenIds): void + { + ProcessAbeRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ProcessAbeRequestToken::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + } + + private function deleteScheduledTasks(array $requestIds, array $tokenIds): void + { + ScheduledTask::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ScheduledTask::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + } + + private function deleteInboxRules(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + InboxRule::query() + ->whereIn('process_request_token_id', $tokenIds) + ->get() + ->each + ->delete(); + } + + private function deleteInboxRuleLogs(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + InboxRuleLog::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteEllucianEthosSyncTasks(array $tokenIds): void + { + if ($tokenIds === [] || !Schema::hasTable('ellucian_ethos_sync_global_task_list')) { + return; + } + + DB::table('ellucian_ethos_sync_global_task_list') + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteTaskDrafts(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + TaskDraft::query() + ->whereIn('task_id', $tokenIds) + ->delete(); + } + + private function deleteTaskDraftMedia(array $draftIds): void + { + if ($draftIds === []) { + return; + } + + Media::query() + ->where('model_type', TaskDraft::class) + ->whereIn('model_id', $draftIds) + ->get() + ->each + ->delete(); + } + + private function deleteRequestMedia(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + Media::query() + ->where('model_type', ProcessRequest::class) + ->whereIn('model_id', $requestIds) + ->get() + ->each + ->delete(); + } + + private function deleteComments(string | array $caseNumbers, array $requestIds, array $tokenIds): void + { + 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); + + if ($tokenIds !== []) { + $query->orWhere(function ($nestedQuery) use ($tokenIds) { + $nestedQuery->where('commentable_type', ProcessRequestToken::class) + ->whereIn('commentable_id', $tokenIds); + }); + } + }); + } + + $query->delete(); + } + + private function deleteNotifications(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + $notificationTypes = [ + 'COMMENT', + 'FILE_SHARED', + 'TASK_CREATED', + 'TASK_COMPLETED', + 'TASK_REASSIGNED', + ]; + + Notification::query() + ->whereIn('data->request_id', $requestIds) + ->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/Http/Controllers/Api/CaseController.php b/ProcessMaker/Http/Controllers/Api/CaseController.php index caaacfc14d..1d4ba3a891 100644 --- a/ProcessMaker/Http/Controllers/Api/CaseController.php +++ b/ProcessMaker/Http/Controllers/Api/CaseController.php @@ -2,6 +2,8 @@ namespace ProcessMaker\Http\Controllers\Api; +use Illuminate\Http\JsonResponse; +use ProcessMaker\Http\Controllers\Api\Actions\Cases\DeleteCase; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; @@ -12,7 +14,7 @@ class CaseController extends Controller /** * Get stage information for cases */ - public function getStagePerCase($case_number = null) + public function getStagePerCase(?string $case_number = null): JsonResponse { if (!empty($case_number)) { $responseData = $this->getSpecificCaseStages($case_number); @@ -31,12 +33,56 @@ public function getStagePerCase($case_number = null) return response()->json($responseData); } + /** + * Delete a case and its related requests. + * + * @param string $case_number + * @return JsonResponse + * + * @OA\Delete( + * path="/cases/{case_number}", + * summary="Delete a case and its related requests", + * operationId="deleteCase", + * tags={"Cases"}, + * @OA\Parameter( + * description="Case number to delete", + * in="path", + * name="case_number", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response=204, + * description="success" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response(response=404, ref="#/components/responses/404"), + * @OA\Response( + * response=409, + * description="Conflict" + * ), + * @OA\Response( + * response=500, + * description="Internal Server Error" + * ), + * ) + */ + public function destroy(string $case_number): JsonResponse + { + (new DeleteCase)($case_number); + + return response()->json([], 204); + } + /** * Get specific case stages information * @param string $caseNumber The unique identifier of the case to retrieve stages for * @return array */ - private function getSpecificCaseStages($caseNumber) + private function getSpecificCaseStages(string $caseNumber): array { $allRequests = ProcessRequest::where('case_number', $caseNumber)->get(); // Check if any requests were found @@ -75,7 +121,7 @@ private function getSpecificCaseStages($caseNumber) * @param string|null $status The status to set for the stages * @return array */ - private function getDefaultCaseStages($status = null) + private function getDefaultCaseStages(?string $status = null): array { return [ [ @@ -100,7 +146,7 @@ private function getDefaultCaseStages($status = null) * @param string $stageName The name of the stage ('In Progress' or 'Completed') * @return string The mapped status */ - private function mapStatus($status, $stageName) + private function mapStatus(?string $status, string $stageName): string { if ($status === 'COMPLETED') { return 'Done'; @@ -120,11 +166,11 @@ private function mapStatus($status, $stageName) /** * Get the stages summary based on the provided request. * - * @param $requestId + * @param ProcessRequest $request * @return array An array of stage results, each containing the stage ID, name, status, * and completion date. */ - private function getStagesSummary(ProcessRequest $request) + private function getStagesSummary(ProcessRequest $request): array { $requestId = $request->id; $processId = $request->process_id; diff --git a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php new file mode 100644 index 0000000000..4a3ac93795 --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php @@ -0,0 +1,92 @@ +input('filter')); + + $orderBy = $request->input('order_by'); + if ($orderBy && in_array($orderBy, self::LOG_SORT_COLUMNS, true)) { + $orderBy = DB::raw(preg_replace('/\.(.+)/', "->>'\$.$1'", $orderBy, 1)); + + $orderDirection = strtolower((string) $request->input('order_direction', 'asc')); + if (!in_array($orderDirection, ['asc', 'desc'], true)) { + $orderDirection = 'asc'; + } + + $query->orderBy($orderBy, $orderDirection); + } else { + $query->orderByDesc('created_at'); + } + + $response = $query->paginate($request->input('per_page', 10)); + + return new ApiCollection($response); + } + + /** + * Queue a CSV export to disk; user receives a database + broadcast notification with a signed download URL when ready. + */ + public function queueExportCsv(Request $request): JsonResponse + { + $request->validate([ + 'filter' => ['sometimes', 'nullable', 'string'], + ]); + + $exportToken = (string) Str::uuid(); + DownloadCaseRetentionLogExport::dispatch($request->user(), $request->input('filter'), $exportToken); + + return response()->json([ + 'success' => true, + 'message' => __('The file is processing. You may continue working while the log file compiles.'), + ]); + } + + /** + * Signed URL only (no API token). Link is included in the export-ready notification when the job finishes. + */ + public function downloadExportFile(Request $request, string $token): BinaryFileResponse + { + if (!Str::isUuid($token)) { + abort(404); + } + + $relativePath = 'exports/case-retention/' . $token . '.csv'; + if (!Storage::disk('local')->exists($relativePath)) { + abort(404); + } + + return response()->download( + Storage::disk('local')->path($relativePath), + 'case_retention_policy_logs.csv', + ['Content-Type' => 'text/csv; charset=UTF-8'], + )->deleteFileAfterSend(true); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 399323ea0c..8994184d1a 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -355,12 +355,14 @@ public function removeSharedAsset($id) public function installRemoteAsset(Request $request, DevLink $devLink) { + $updateType = $request->input('updateType', DevLinkInstall::MODE_UPDATE); + DevLinkInstall::dispatch( $request->user()->id, $devLink->id, $request->input('class'), $request->input('id'), - DevLinkInstall::MODE_UPDATE, + $updateType, DevLinkInstall::TYPE_IMPORT_ASSET ); diff --git a/ProcessMaker/Http/Controllers/Api/FileController.php b/ProcessMaker/Http/Controllers/Api/FileController.php index 8cd1de0171..47ad5560a9 100644 --- a/ProcessMaker/Http/Controllers/Api/FileController.php +++ b/ProcessMaker/Http/Controllers/Api/FileController.php @@ -13,10 +13,13 @@ use ProcessMaker\Models\MediaLog; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\TaskDraft; +use ProcessMaker\Traits\ValidatesFileTrait; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class FileController extends Controller { + use ValidatesFileTrait; + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -188,7 +191,21 @@ public function store(Request $request) } $mediaCollection = $request->input('collection', 'local'); + + // Validate the file before processing + $uploadedFile = $request->file('file'); + if (!$uploadedFile) { + return abort(response(['message' => 'No file provided'], 422)); + } + + $errors = []; + $this->validateFile($uploadedFile, $errors); + if (count($errors) > 0) { + return abort(response($errors, 422)); + } + $file = $model->addMediaFromRequest('file'); + $user = pmUser(); $originalCreatedBy = $user ? $user->id : null; $data_name = $request->input('data_name', ''); @@ -284,7 +301,19 @@ public function download(Media $file) { $path = Storage::disk('public')->path($file->id . '/' . $file->file_name); - // Register the Event + // Inline preview when requested + if (request()->boolean('inline', false)) { + if (!empty($file->file_name)) { + FilesDownloaded::dispatch($file); + } + + return response()->file($path, [ + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => 'inline; filename="' . addslashes($file->file_name) . '"', + ]); + } + + // Default: force download if (!empty($file->file_name)) { FilesDownloaded::dispatch($file); } diff --git a/ProcessMaker/Http/Controllers/Api/PermissionController.php b/ProcessMaker/Http/Controllers/Api/PermissionController.php index be05665a3c..5fed46c80b 100644 --- a/ProcessMaker/Http/Controllers/Api/PermissionController.php +++ b/ProcessMaker/Http/Controllers/Api/PermissionController.php @@ -122,16 +122,9 @@ public function update(Request $request) //Sync the entity's permissions with the database $entity->permissions()->sync($permissions->pluck('id')->toArray()); - // Clear user permissions cache and rebuild - $this->clearAndRebuildCache($entity); + // The PermissionUpdated event will automatically trigger cache invalidation + // via the InvalidatePermissionCacheOnUpdate listener return response([], 204); } - - private function clearAndRebuildCache($user) - { - // Rebuild and update the permissions cache - $permissions = $user->permissions()->pluck('name')->toArray(); - Cache::put("user_{$user->id}_permissions", $permissions, 86400); - } } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index b4fb419573..1578e8df17 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -19,6 +19,7 @@ use ProcessMaker\Http\Controllers\Api\GroupController; use ProcessMaker\Http\Controllers\Api\TemplateController; use ProcessMaker\Http\Controllers\Controller; +use ProcessMaker\Http\Requests\ProcessUpdateRequest; use ProcessMaker\Http\Resources\ApiCollection; use ProcessMaker\Http\Resources\ApiResource; use ProcessMaker\Http\Resources\Process as Resource; @@ -44,6 +45,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 +226,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) { @@ -429,7 +436,7 @@ public function store(Request $request) //set manager id if ($request->has('manager_id')) { - $process->manager_id = $request->input('manager_id', null); + $process->manager_id = $this->validateMaxManagers($request); } if (isset($data['bpmn'])) { @@ -463,7 +470,7 @@ public function store(Request $request) /** * Updates the current element. * - * @param Request $request + * @param ProcessUpdateRequest $request * @param Process $process * @return ResponseFactory|Response * @@ -494,21 +501,13 @@ public function store(Request $request) * ), * ) */ - public function update(Request $request, Process $process) + public function update(ProcessUpdateRequest $request, Process $process) { $lastVersion = $process->getDraftOrPublishedLatestVersion(); $process->bpmn = $lastVersion->bpmn; $process->alternative = $lastVersion->alternative; $process->stages = $lastVersion->stages; - $rules = Process::rules($process); - if (!$request->has('name')) { - unset($rules['name']); - } - if ($request->has('default_for_anon_webentry')) { - $rules = ['language_code' => 'required_if:default_for_anon_webentry,true']; - } - $request->validate($rules); $original = $process->getOriginal(); // Replace html entities with the correct characters @@ -542,7 +541,7 @@ public function update(Request $request, Process $process) $process->fill($request->except('notifications', 'task_notifications', 'notification_settings', 'cancel_request', 'cancel_request_id', 'start_request_id', 'edit_data', 'edit_data_id', 'projects')); if ($request->has('manager_id')) { - $process->manager_id = $request->input('manager_id', null); + $process->manager_id = $this->validateMaxManagers($request); } if ($request->has('user_id')) { @@ -601,6 +600,17 @@ public function update(Request $request, Process $process) } } + // Non-administrators cannot change retention metadata: persist pre-request values. + if (!auth()->user()->is_administrator) { + $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(); @@ -621,6 +631,95 @@ public function update(Request $request, Process $process) return new Resource($process->refresh()); } + private function validateMaxManagers(Request $request) + { + $managerIds = $request->input('manager_id', []); + + // Handle different input types + if (is_string($managerIds)) { + // If it's a string, try to decode it as JSON + if (empty($managerIds)) { + $managerIds = []; + } else { + $decoded = json_decode($managerIds, true); + + // Handle JSON decode failure + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + ['manager_id' => [__('Invalid JSON format for manager_id')]] + ); + } + + $managerIds = $decoded; + } + } + + // Ensure we have an array + if (!is_array($managerIds)) { + // If it's a single value (not array), convert to array + $managerIds = [$managerIds]; + } + + // Filter out null, empty values and validate each manager ID + $managerIds = array_filter($managerIds, function ($id) { + return $id !== null && $id !== '' && is_numeric($id) && $id > 0; + }); + + // Re-index the array to remove gaps from filtered values + $managerIds = array_values($managerIds); + + // Validate maximum number of managers + if (count($managerIds) > 10) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + ['manager_id' => [__('Maximum number of managers is :max', ['max' => 10])]] + ); + } + + return $managerIds; + } + + /** + * Re-apply retention-related keys on $process->properties from the model snapshot taken before fill(). + * Non-admins cannot add these keys if absent originally, or change values if present. + * + * @param array $original + */ + private function restoreProcessRetentionPropertiesFromOriginal(Process $process, array $original): void + { + $originalProperties = $original['properties'] ?? null; + if (is_string($originalProperties)) { + $decoded = json_decode($originalProperties, true); + $originalProperties = is_array($decoded) ? $decoded : []; + } + if (!is_array($originalProperties)) { + $originalProperties = []; + } + + $properties = $process->properties; + if (!is_array($properties)) { + $properties = []; + } + + $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]; + } else { + unset($properties[$key]); + } + } + + $process->properties = $properties; + } + /** * Validate the structure of stages. * @@ -1714,7 +1813,7 @@ protected function checkUserCanStartProcess($event, $currentUser, $process, $req } break; case 'process_manager': - $response = $currentUser === $process->manager_id; + $response = in_array($currentUser, $process->manager_id ?? []); break; } } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index 6508bba81b..54d50f1f1f 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php @@ -32,6 +32,7 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\User; +use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Nayra\Contracts\Bpmn\CatchEventInterface; use ProcessMaker\Notifications\ProcessCanceledNotification; use ProcessMaker\Query\SyntaxError; @@ -609,6 +610,19 @@ private function cancelRequestToken(ProcessRequest $request) // Close process request $request->status = 'CANCELED'; $request->save(); + + // Close any token still open after status is CANCELED (race: task submit commits after CancelRequest job). + ProcessRequestToken::query() + ->where('process_request_id', $request->getKey()) + ->where('status', '!=', ActivityInterface::TOKEN_STATE_CLOSED) + ->update([ + 'status' => ActivityInterface::TOKEN_STATE_CLOSED, + 'completed_at' => now(), + 'due_at' => null, + 'riskchanges_at' => null, + 'user_id' => null, + ]); + // Update case status CaseUpdateStatus::dispatchSync($request); diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php index 1e606ca5f4..730da17d32 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php @@ -3,17 +3,13 @@ namespace ProcessMaker\Http\Controllers\Api; use Exception; -use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException; use Pion\Laravel\ChunkUpload\Handler\AbstractHandler; -use Pion\Laravel\ChunkUpload\Handler\HandlerFactory; use Pion\Laravel\ChunkUpload\Receiver\FileReceiver; use ProcessMaker\Events\FilesAccessed; use ProcessMaker\Events\FilesCreated; @@ -21,14 +17,16 @@ use ProcessMaker\Events\FilesDownloaded; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Http\Resources\ApiCollection; -use ProcessMaker\Http\Resources\ApiResource; use ProcessMaker\Models\Media; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\TaskDraft; +use ProcessMaker\Traits\ValidatesFileTrait; use Spatie\MediaLibrary\MediaCollections\Exceptions\FileIsTooBig; class ProcessRequestFileController extends Controller { + use ValidatesFileTrait; + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -439,126 +437,4 @@ public function destroy(Request $laravel_request, ProcessRequest $request, $file return response([], 204); } - - /** - * Validate uploaded file for security and type restrictions - * - * @param UploadedFile $file - * @param array $errors - * @return array - */ - private function validateFile(UploadedFile $file, &$errors) - { - // Explicitly reject archive files for security - if (config('files.enable_dangerous_validation')) { - $this->rejectArchiveFiles($file, $errors); - } - - // Validate file extension if enabled - if (config('files.enable_extension_validation')) { - $this->validateFileExtension($file, $errors); - } - - // Validate MIME type vs extension if enabled - if (config('files.enable_mime_validation')) { - $this->validateExtensionMimeTypeMatch($file, $errors); - } - - // Validate specific file types (e.g., PDF for JavaScript content) - if (strtolower($file->getClientOriginalExtension()) === 'pdf') { - $this->validatePDFFile($file, $errors); - } - - return $errors; - } - - /** - * Explicitly reject archive files for security reasons - * - * @param UploadedFile $file - * @param array $errors - * @return void - */ - private function rejectArchiveFiles(UploadedFile $file, &$errors) - { - $dangerousExtensions = config('files.dangerous_extensions'); - - $fileExtension = strtolower($file->getClientOriginalExtension()); - - if (in_array($fileExtension, $dangerousExtensions)) { - $errors['message'] = __('Uploaded file type is not allowed'); - - return; - } - - // Also check MIME types for archive files - $dangerousMimeTypes = config('files.dangerous_mime_types'); - - $fileMimeType = $file->getMimeType(); - - if (in_array($fileMimeType, $dangerousMimeTypes)) { - $errors['message'] = __('Uploaded mime file type is not allowed'); - } - } - - /** - * Validate that file extension matches the MIME type - * - * @param UploadedFile $file - * @param array $errors - * @return void - */ - private function validateExtensionMimeTypeMatch(UploadedFile $file, &$errors) - { - $fileExtension = strtolower($file->getClientOriginalExtension()); - $fileMimeType = $file->getMimeType(); - - // Get extension to MIME type mapping from configuration - $extensionMimeMap = config('files.extension_mime_map'); - - // Check if extension exists in our map - if (!isset($extensionMimeMap[$fileExtension])) { - $errors['message'] = __('File extension not allowed'); - - return; - } - - // Check if MIME type matches any of the expected types for this extension - if (!in_array($fileMimeType, $extensionMimeMap[$fileExtension])) { - $errors['message'] = __('The file extension does not match the actual file content'); - } - } - - /** - * Validate file extension against allowed extensions - * - * @param UploadedFile $file - * @param array $errors - * @return void - */ - private function validateFileExtension(UploadedFile $file, &$errors) - { - $allowedExtensions = config('files.allowed_extensions'); - $fileExtension = strtolower($file->getClientOriginalExtension()); - - if (!in_array($fileExtension, $allowedExtensions)) { - $errors['message'] = __('File extension not allowed'); - } - } - - private function validatePDFFile(UploadedFile $file, &$errors) - { - $text = $file->get(); - - $jsKeywords = ['/JavaScript', '<< /S /JavaScript']; - - foreach ($jsKeywords as $keyword) { - if (strpos($text, $keyword) !== false) { - $errors[] = __('Dangerous PDF file content'); - break; - } - } - - return $errors; - } } diff --git a/ProcessMaker/Http/Controllers/Api/ScreenController.php b/ProcessMaker/Http/Controllers/Api/ScreenController.php index 3428ede8ad..d0545d6042 100644 --- a/ProcessMaker/Http/Controllers/Api/ScreenController.php +++ b/ProcessMaker/Http/Controllers/Api/ScreenController.php @@ -206,8 +206,13 @@ public function index(Request $request) * ), * ) */ - public function show(Screen $screen) + public function show(Request $request, Screen $screen) { + $include = $request->input('include', ''); + if ($include) { + $screen->load(explode(',', $include)); + } + return new ScreenResource($screen); } diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index ae85d35821..2564272691 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -59,7 +59,15 @@ public function index(Request $request) { $this->checkAuth($request); - return new ApiCollection(ScriptExecutor::nonSystem()->get()); + $query = ScriptExecutor::nonSystem(); + + if ($request->has('order_by')) { + $order_by = $request->input('order_by'); + $order_direction = $request->input('order_direction', 'ASC'); + $query->orderBy($order_by, $order_direction); + } + + return new ApiCollection($query->get()); } /** diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index cd4cb5fd1c..0679b42853 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -134,6 +134,18 @@ public function index(Request $request, $getTotal = false, User $user = null) $query = $this->indexBaseQuery($request); + // Get fields from request (sent by frontend) + // If not provided, don't apply select() to maintain backward compatibility (returns all columns) + $fields = $request->input('fields', ''); + if ($fields) { + $selectedFields = explode(',', $fields); + // Ensure 'id' is always included for internal logic (e.g., inOverdueQuery at line ~186) + if (!in_array('id', $selectedFields)) { + $selectedFields[] = 'id'; + } + $query = $query->select($selectedFields); + } + $this->applyFilters($query, $request); $this->excludeNonVisibleTasks($query, $request); @@ -142,19 +154,20 @@ public function index(Request $request, $getTotal = false, User $user = null) $this->applyStatusFilter($query, $request); + // Apply process manager filter BEFORE PMQL to avoid conflicts with is_self_service filtering + if ($request->input('processesIManage') === 'true') { + $this->applyProcessManager($query, $user, $request); + } else { + $this->applyForCurrentUser($query, $user); + } + $this->applyPmql($query, $request, $user); $this->applyAdvancedFilter($query, $request); - $this->applyForCurrentUser($query, $user); - // Apply filter overdue $query->overdue($request->input('overdue')); - if ($request->input('processesIManage') === 'true') { - $this->applyProcessManager($query, $user); - } - // If only the total is being requested (by a Saved Search), send it now if ($getTotal === true) { return $query->count(); @@ -168,6 +181,11 @@ public function index(Request $request, $getTotal = false, User $user = null) $response = $this->applyUserFilter($response, $request, $user); + if ($response->total() > 0 && $request->input('processesIManage') === 'true') { + // enable user manager in cache + $this->enableUserManager($user); + } + $inOverdueQuery = ProcessRequestToken::query() ->whereIn('id', $response->pluck('id')) ->where('due_at', '<', Carbon::now()); @@ -335,7 +353,8 @@ public function update(Request $request, ProcessRequestToken $task) return new Resource($task->refresh()); } elseif (!empty($request->input('user_id'))) { $userToAssign = $request->input('user_id'); - $task->reassign($userToAssign, $request->user()); + $comments = $request->input('comments'); + $task->reassign($userToAssign, $request->user(), $comments); $taskRefreshed = $task->refresh(); @@ -420,7 +439,7 @@ public function setPriority(Request $request, ProcessRequestToken $task) } /** - * Only send data for a screen’s fields + * Only send data for a screen's fields * * @param ProcessRequestToken $task * diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index 34c3d6c758..d08e3374db 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -183,11 +183,59 @@ public function index(Request $request) * ), * ), * ) + * + * @OA\Post( + * path="/users_task_count", + * summary="Returns all users and their total tasks (POST version for large form_data)", + * operationId="postUsersTaskCount", + * tags={"Users"}, + * @OA\RequestBody( + * description="Request body for filtering users", + * @OA\JsonContent( + * @OA\Property( + * property="filter", + * type="string", + * description="Filter results by string. Searches First Name, Last Name, Email, or Username." + * ), + * @OA\Property( + * property="include_ids", + * type="string", + * description="Comma separated list of user IDs to include in the response. Eg. 1,2,3" + * ), + * @OA\Property( + * property="assignable_for_task_id", + * type="integer", + * description="Task ID to get assignable users for" + * ), + * @OA\Property( + * property="form_data", + * type="object", + * description="Form data used to evaluate rule expressions for task assignment" + * ), + * ), + * ), + * @OA\Response( + * response=200, + * description="List of users with task counts", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="data", + * type="array", + * @OA\Items(ref="#/components/schemas/users"), + * ), + * @OA\Property( + * property="meta", + * type="object", + * ref="#/components/schemas/metadata", + * ), + * ), + * ), + * ) */ public function getUsersTaskCount(Request $request) { - $query = User::nonSystem(); - $query->select('id', 'username', 'firstname', 'lastname'); + $query = User::select('id', 'username', 'firstname', 'lastname'); $filter = $request->input('filter', ''); if (!empty($filter)) { @@ -199,19 +247,21 @@ public function getUsersTaskCount(Request $request) }); } - $query->where('status', 'ACTIVE'); - - $query->withCount('activeTasks'); - $include_ids = []; $include_ids_string = $request->input('include_ids', ''); if (!empty($include_ids_string)) { $include_ids = explode(',', $include_ids_string); } elseif ($request->has('assignable_for_task_id')) { - $task = ProcessRequestToken::findOrFail($request->input('assignable_for_task_id')); - if ($task->getAssignmentRule() === 'user_group') { - // Limit the list of users to those that can be assigned to the task - $include_ids = $task->process->getAssignableUsers($task->element_id); + $processRequestToken = ProcessRequestToken::findOrFail($request->input('assignable_for_task_id')); + if (config('app.reassign_restrict_to_assignable_users')) { + $include_ids = $processRequestToken->process->getAssignableUsersByAssignmentType($processRequestToken); + $assignmentRule = $processRequestToken->getAssignmentRule(); + if ($assignmentRule === 'rule_expression' && $request->has('form_data')) { + $include_ids = $processRequestToken->getAssigneesFromExpression($request->input('form_data')); + } + if ($assignmentRule === 'process_variable' && $request->has('form_data')) { + $include_ids = $processRequestToken->getUsersFromProcessVariable($request->input('form_data')); + } } } @@ -219,10 +269,14 @@ public function getUsersTaskCount(Request $request) $query->whereIn('id', $include_ids); } - $response = $query->orderBy( - $request->input('order_by', 'username'), - $request->input('order_direction', 'ASC') - ) + $response = $query + ->where('is_system', false) + ->where('status', 'ACTIVE') + ->withCount('activeTasks') + ->orderBy( + $request->input('order_by', 'username'), + $request->input('order_direction', 'ASC') + ) ->paginate(50); return new ApiCollection($response); @@ -358,8 +412,8 @@ public function getPinnnedControls(User $user) $meta = $user->meta ? (array) $user->meta : []; return array_key_exists('pinnedControls', $meta) - ? $meta['pinnedControls'] - : []; + ? $meta['pinnedControls'] + : []; } /** @@ -773,10 +827,12 @@ private function uploadAvatar(User $user, Request $request) // Validate image content if ($type === 'svg') { // For SVG files, validate against XSS - if (preg_match('/ - - diff --git a/resources/js/admin/cases-retention/components/CaseIdsTableCell.vue b/resources/js/admin/cases-retention/components/CaseIdsTableCell.vue new file mode 100644 index 0000000000..8710bc181e --- /dev/null +++ b/resources/js/admin/cases-retention/components/CaseIdsTableCell.vue @@ -0,0 +1,172 @@ + + + + + + + + diff --git a/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue b/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue new file mode 100644 index 0000000000..ea6f5ff671 --- /dev/null +++ b/resources/js/admin/cases-retention/components/CasesRetentionLogs.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/resources/js/admin/cases-retention/index.js b/resources/js/admin/cases-retention/index.js new file mode 100644 index 0000000000..0491b8ce8c --- /dev/null +++ b/resources/js/admin/cases-retention/index.js @@ -0,0 +1,42 @@ +import CasesRetentionLogs from "./components/CasesRetentionLogs.vue"; + +// Use window.Vue from bootstrap (has vuetable, i18n, etc.) +// eslint-disable-next-line no-unused-vars -- Vue app mounted for side effect +const casesRetentionApp = new window.Vue({ + el: "#casesRetentionIndex", + components: { CasesRetentionLogs }, + data() { + return { + filter: "", + }; + }, + methods: { + downloadRetentionLogs() { + const params = new URLSearchParams(); + if (this.filter) { + params.set("filter", this.filter); + } + const qs = params.toString(); + const path = qs ? `cases-retention/logs/export?${qs}` : "cases-retention/logs/export"; + + ProcessMaker.apiClient + .get(path) + .then((response) => { + if (response.data.success) { + ProcessMaker.alert(response.data.message, "success"); + } else { + ProcessMaker.alert( + response.data.message || "Unable to start export.", + "danger", + ); + } + }) + .catch(() => { + ProcessMaker.alert("Unable to download logs.", "danger"); + }); + }, + reload() { + this.$refs.casesRetentionLogs.reload(); + }, + }, +}); diff --git a/resources/js/admin/devlink/components/AssetListing.vue b/resources/js/admin/devlink/components/AssetListing.vue index af9d61781c..a5716181d3 100644 --- a/resources/js/admin/devlink/components/AssetListing.vue +++ b/resources/js/admin/devlink/components/AssetListing.vue @@ -1,6 +1,6 @@ + + + diff --git a/resources/js/admin/logs/components/Logs/AgentSessionDetail/index.js b/resources/js/admin/logs/components/Logs/AgentSessionDetail/index.js new file mode 100644 index 0000000000..01a900019c --- /dev/null +++ b/resources/js/admin/logs/components/Logs/AgentSessionDetail/index.js @@ -0,0 +1,5 @@ +import AgentSessionDetail from "./AgentSessionDetail.vue"; + +export { AgentSessionDetail }; +export default AgentSessionDetail; + diff --git a/resources/js/admin/logs/components/Logs/BaseTable/BaseTable.vue b/resources/js/admin/logs/components/Logs/BaseTable/BaseTable.vue new file mode 100644 index 0000000000..da4b99b837 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/BaseTable/BaseTable.vue @@ -0,0 +1,87 @@ + + + diff --git a/resources/js/admin/logs/components/Logs/BaseTable/index.js b/resources/js/admin/logs/components/Logs/BaseTable/index.js new file mode 100644 index 0000000000..f8430fb194 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/BaseTable/index.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as BaseTable } from './BaseTable.vue'; + diff --git a/resources/js/admin/logs/components/Logs/HeaderBar/HeaderBar.vue b/resources/js/admin/logs/components/Logs/HeaderBar/HeaderBar.vue new file mode 100644 index 0000000000..6878c1c6fc --- /dev/null +++ b/resources/js/admin/logs/components/Logs/HeaderBar/HeaderBar.vue @@ -0,0 +1,148 @@ + + + + diff --git a/resources/js/admin/logs/components/Logs/HeaderBar/index.js b/resources/js/admin/logs/components/Logs/HeaderBar/index.js new file mode 100644 index 0000000000..c2176bf01c --- /dev/null +++ b/resources/js/admin/logs/components/Logs/HeaderBar/index.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as HeaderBar } from './HeaderBar.vue'; + diff --git a/resources/js/admin/logs/components/Logs/LogContainer/LogContainer.vue b/resources/js/admin/logs/components/Logs/LogContainer/LogContainer.vue new file mode 100644 index 0000000000..9825c0a42e --- /dev/null +++ b/resources/js/admin/logs/components/Logs/LogContainer/LogContainer.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/resources/js/admin/logs/components/Logs/LogContainer/index.js b/resources/js/admin/logs/components/Logs/LogContainer/index.js new file mode 100644 index 0000000000..d0ad929b7e --- /dev/null +++ b/resources/js/admin/logs/components/Logs/LogContainer/index.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as LogContainer } from './LogContainer.vue'; + diff --git a/resources/js/admin/logs/components/Logs/LogTable/LogTable.vue b/resources/js/admin/logs/components/Logs/LogTable/LogTable.vue new file mode 100644 index 0000000000..690552e324 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/LogTable/LogTable.vue @@ -0,0 +1,317 @@ + + + + diff --git a/resources/js/admin/logs/components/Logs/LogTable/index.js b/resources/js/admin/logs/components/Logs/LogTable/index.js new file mode 100644 index 0000000000..d07b1072e7 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/LogTable/index.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as LogTable } from './LogTable.vue'; + diff --git a/resources/js/admin/logs/components/Logs/Pagination/Pagination.vue b/resources/js/admin/logs/components/Logs/Pagination/Pagination.vue new file mode 100644 index 0000000000..33b47377b4 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/Pagination/Pagination.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/resources/js/admin/logs/components/Logs/Pagination/index.js b/resources/js/admin/logs/components/Logs/Pagination/index.js new file mode 100644 index 0000000000..d2d0ae6aa6 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/Pagination/index.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as Pagination } from './Pagination.vue'; + diff --git a/resources/js/admin/logs/components/Logs/Sidebar/Sidebar.vue b/resources/js/admin/logs/components/Logs/Sidebar/Sidebar.vue new file mode 100644 index 0000000000..a2276bbff7 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/Sidebar/Sidebar.vue @@ -0,0 +1,196 @@ + + + diff --git a/resources/js/admin/logs/components/Logs/Sidebar/index.js b/resources/js/admin/logs/components/Logs/Sidebar/index.js new file mode 100644 index 0000000000..a4f8b5ce94 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/Sidebar/index.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as Sidebar } from './Sidebar.vue'; + diff --git a/resources/js/admin/logs/components/Logs/routes.js b/resources/js/admin/logs/components/Logs/routes.js new file mode 100644 index 0000000000..398d462193 --- /dev/null +++ b/resources/js/admin/logs/components/Logs/routes.js @@ -0,0 +1,92 @@ +import { LogTable } from "./LogTable"; + +export default {}; + +/** + * Check if a package is installed + * @param {string} packageName - The package name to check + * @returns {boolean} + */ +const isPackageInstalled = (packageName) => window.ProcessMaker?.packages?.includes(packageName); + +/** + * Check if email start event package is installed + * @returns {boolean} + */ +export const hasEmailPackage = () => isPackageInstalled("package-email-start-event"); + +/** + * Check if AI package is installed + * @returns {boolean} + */ +export const hasAiPackage = () => isPackageInstalled("package-ai"); + +/** + * Determine the default redirect path based on installed packages + * @returns {string} + */ +const getDefaultRedirectPath = () => { + if (hasEmailPackage()) { + return "/email/errors"; + } + if (hasAiPackage()) { + return "/agents/design"; + } + // Fallback - shouldn't happen if menu visibility is correct + return "/email/errors"; +}; + +export const routes = [ + { + name: "logs.index", + path: "/", + beforeEnter: (to, from, next) => { + next(getDefaultRedirectPath()); + }, + }, + // Email logs routes + { + name: "logs.email", + path: "/email/:logType", + component: LogTable, + props(route) { + return { + category: "email", + logType: route.params.logType, + }; + }, + beforeEnter: (to, from, next) => { + if (!hasEmailPackage()) { + // Redirect to agents if email package not installed + next(hasAiPackage() ? "/agents/design" : "/"); + } else { + next(); + } + }, + }, + // FlowGenie Agents logs routes + { + name: "logs.agents.redirect", + path: "/agents", + redirect: "/agents/design", + }, + { + name: "logs.agents", + path: "/agents/:logType", + component: LogTable, + props(route) { + return { + category: "agents", + logType: route.params.logType, + }; + }, + beforeEnter: (to, from, next) => { + if (!hasAiPackage()) { + // Redirect to email if AI package not installed + next(hasEmailPackage() ? "/email/errors" : "/"); + } else { + next(); + } + }, + }, +]; diff --git a/resources/js/admin/logs/index.js b/resources/js/admin/logs/index.js new file mode 100644 index 0000000000..bdf9dc220b --- /dev/null +++ b/resources/js/admin/logs/index.js @@ -0,0 +1,26 @@ +import { LogContainer } from './components/Logs/LogContainer'; +import { routes } from './components/Logs/routes'; + +// eslint-disable-next-line no-undef +Vue.use(VueRouter); + +// eslint-disable-next-line no-undef +const router = new VueRouter({ + mode: 'history', + base: '/admin/logs', + routes, +}); + +window.Vue.component('admin-logs', LogContainer); + +document.addEventListener('DOMContentLoaded', () => { + new window.Vue({ + el: '#admin-logs-main', + router, + components: { + LogContainer, + }, + render: (h) => h(LogContainer), + }); +}); + diff --git a/resources/js/admin/logs/utils/date.js b/resources/js/admin/logs/utils/date.js new file mode 100644 index 0000000000..491fa1a8d8 --- /dev/null +++ b/resources/js/admin/logs/utils/date.js @@ -0,0 +1,74 @@ +/* eslint-disable import/prefer-default-export */ +import { DateTime } from 'luxon'; + +/** + * Convert PHP/moment date format to Luxon format + * @param {string} format - PHP/moment format string + * @returns {string} - Luxon format string + */ +const convertToLuxonFormat = (format) => { + // Common conversions from PHP/moment to Luxon + const replacements = { + YYYY: 'yyyy', + YY: 'yy', + MM: 'LL', + M: 'L', + DD: 'dd', + D: 'd', + HH: 'HH', + hh: 'hh', + H: 'H', + h: 'h', + mm: 'mm', + m: 'm', + ss: 'ss', + s: 's', + A: 'a', + a: 'a', + }; + + let luxonFormat = format; + Object.entries(replacements).forEach(([from, to]) => { + luxonFormat = luxonFormat.replace(new RegExp(from, 'g'), to); + }); + + return luxonFormat; +}; + +/** + * Format date to user's date format + * @param {string} value - The date to format + * @returns {string} - The formatted date + */ +export const dateFormatter = (value) => { + let datetimeConfig = 'dd/LL/yyyy hh:mm'; + let timezoneConfig = 'UTC'; + + if ( + typeof ProcessMaker !== 'undefined' + && ProcessMaker.user + && ProcessMaker.user.datetime_format + ) { + timezoneConfig = ProcessMaker.user.timezone; + datetimeConfig = convertToLuxonFormat(ProcessMaker.user.datetime_format); + } + + if (value) { + const date = DateTime.fromISO(value, { zone: 'utc' }).setZone(timezoneConfig); + + if (date.isValid) { + return date.toFormat(datetimeConfig); + } + + // Try parsing as SQL format + const sqlDate = DateTime.fromSQL(value, { zone: 'utc' }).setZone(timezoneConfig); + if (sqlDate.isValid) { + return sqlDate.toFormat(datetimeConfig); + } + + return value; + } + + return '-'; +}; + diff --git a/resources/js/admin/settings/components/SettingCheckboxes.vue b/resources/js/admin/settings/components/SettingCheckboxes.vue index 9d5ca042f8..3fbb913802 100644 --- a/resources/js/admin/settings/components/SettingCheckboxes.vue +++ b/resources/js/admin/settings/components/SettingCheckboxes.vue @@ -7,7 +7,7 @@ {{ trimmed(text) }} -