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/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/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..7edd255225 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,9 @@ protected function schedule(Schedule $schedule) $schedule->command('metrics:clear')->cron("*/{$clearInterval} * * * *"); break; } + + // 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/Http/Controllers/Admin/DevLinkController.php b/ProcessMaker/Http/Controllers/Admin/DevLinkController.php index 606a4fe14f..296419925b 100644 --- a/ProcessMaker/Http/Controllers/Admin/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Admin/DevLinkController.php @@ -36,20 +36,24 @@ public function getOauthClient(Request $request) $devLinkId = $request->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/QueuesController.php b/ProcessMaker/Http/Controllers/Admin/QueuesController.php index b156f05b7c..0dc78b5369 100644 --- a/ProcessMaker/Http/Controllers/Admin/QueuesController.php +++ b/ProcessMaker/Http/Controllers/Admin/QueuesController.php @@ -5,18 +5,28 @@ use Illuminate\Auth\Access\AuthorizationException; use ProcessMaker\Events\QueueManagementAccessed; use ProcessMaker\Http\Controllers\Controller; +use ProcessMaker\Providers\TenantQueueServiceProvider; class QueuesController extends Controller { public function index() { - if (auth()->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..6d34bcf7b7 --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -0,0 +1,118 @@ +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(); + } + + private function dispatchSavedSearchRecount(): void + { + if (!config('savedsearch.count', false)) { + return; + } + + $jobClass = 'ProcessMaker\\Package\\SavedSearch\\Jobs\\RecountAllSavedSearches'; + if (!class_exists($jobClass)) { + return; + } + + DB::afterCommit(static function () use ($jobClass): void { + $jobClass::dispatch(['request', 'task']); + }); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php new file mode 100644 index 0000000000..f59850a67b --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php @@ -0,0 +1,223 @@ +where('case_number', $caseNumber) + ->delete(); + } + + private function deleteCasesParticipated(string $caseNumber): void + { + CaseParticipated::query() + ->where('case_number', $caseNumber) + ->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 $caseNumber, array $requestIds, array $tokenIds): void + { + Comment::query() + ->where('case_number', $caseNumber) + ->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); + }); + } + }) + ->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(); + } +} 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/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..2aa2e217b6 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', ''); 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..9e1e0af561 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; @@ -429,7 +430,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 +464,7 @@ public function store(Request $request) /** * Updates the current element. * - * @param Request $request + * @param ProcessUpdateRequest $request * @param Process $process * @return ResponseFactory|Response * @@ -494,21 +495,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 +535,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')) { @@ -621,6 +614,55 @@ 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; + } + /** * Validate the structure of stages. * @@ -1714,7 +1756,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/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/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..af2948abf8 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); + } 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/components/Sidebaricon.vue b/resources/js/components/Sidebaricon.vue index 8f34d59251..99784663f7 100644 --- a/resources/js/components/Sidebaricon.vue +++ b/resources/js/components/Sidebaricon.vue @@ -3,7 +3,12 @@ - + {{item.title}} @@ -19,7 +24,12 @@ - {{item.title}}{{ count }} + + {{item.title}}{{ count }} {{item.title}} {{ count }} @@ -73,7 +83,20 @@ }, expanded() { return this.$parent.expanded - } + }, + maskStyle(file) { + return { + backgroundColor: 'currentColor', + WebkitMaskImage: `url(${file})`, + maskImage: `url(${file})`, + WebkitMaskRepeat: 'no-repeat', + maskRepeat: 'no-repeat', + WebkitMaskPosition: 'center', + maskPosition: 'center', + WebkitMaskSize: 'contain', + maskSize: 'contain', + }; + } } } diff --git a/resources/js/components/shared/IconDropdown.vue b/resources/js/components/shared/IconDropdown.vue index 01cd6a0e6b..ba9d8cc1ad 100644 --- a/resources/js/components/shared/IconDropdown.vue +++ b/resources/js/components/shared/IconDropdown.vue @@ -134,7 +134,7 @@ export default { }; - \ No newline at end of file + diff --git a/resources/js/components/templates/SelectTemplateModal.vue b/resources/js/components/templates/SelectTemplateModal.vue index f036055635..9563f46e16 100644 --- a/resources/js/components/templates/SelectTemplateModal.vue +++ b/resources/js/components/templates/SelectTemplateModal.vue @@ -27,7 +27,8 @@ @ai-process-button-clicked="createAiProcess()" @process-intelligence-clicked="importPI()" :showTemplateOptionsActionBar="true" - :package-ai="packageAi" /> + :package-ai="packageAi" + :show-pi-import="showPiImport" /> - \ No newline at end of file + diff --git a/resources/js/components/templates/TemplateSearch.vue b/resources/js/components/templates/TemplateSearch.vue index e64dabffa0..4193389314 100644 --- a/resources/js/components/templates/TemplateSearch.vue +++ b/resources/js/components/templates/TemplateSearch.vue @@ -36,7 +36,7 @@ /> -
+
+ :event-parent-id="_uid" + @load="loaded()" + />
- \ No newline at end of file + }, true); + } catch (e) { + // Cross-origin restrictions may prevent access to iframe content + // eslint-disable-next-line no-console + console.warn("Could not setup button click listener in iframe:", e.message); + } + }, + }, +}; + diff --git a/resources/js/next/config/session.js b/resources/js/next/config/session.js index 86d35bba19..c1d54fc23d 100644 --- a/resources/js/next/config/session.js +++ b/resources/js/next/config/session.js @@ -1,4 +1,5 @@ import { getGlobalPMVariable, setGlobalPMVariables, getGlobalVariable } from "../globalVariables"; +import { initSessionSync } from "../../common/sessionSync"; export default () => { const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content; @@ -7,95 +8,46 @@ export default () => { const Echo = getGlobalVariable("Echo"); const pushNotification = getGlobalPMVariable("pushNotification"); - const closeSessionModal = getGlobalPMVariable("closeSessionModal"); const alert = getGlobalPMVariable("alert"); const user = getGlobalPMVariable("user"); + const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; - const isSameDevice = (e) => { - const localDeviceId = Vue.$cookies.get(e.device_variable); - const remoteDeviceId = e.device_id; - return localDeviceId && localDeviceId === remoteDeviceId; - }; - - if (user) { - // Session timeout - const AccountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content)); - const AccountTimeoutWarnSeconds = parseInt(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); - const AccountTimeoutEnabled = document.head.querySelector("meta[name=\"timeout-enabled\"]") ? parseInt(document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content) : 1; - const AccountTimeoutWorker = new Worker(timeoutScript); - - AccountTimeoutWorker.addEventListener("message", (e) => { - const sessionModal = getGlobalPMVariable("sessionModal"); - if (e.data.method === "countdown") { - sessionModal( - "Session Warning", - "

Your user session is expiring. If your session expires, all of your unsaved data will be lost.

Would you like to stay connected?

", - e.data.data.time, - AccountTimeoutWarnSeconds, - ); - } - if (e.data.method === "timedOut") { - window.location = "/logout?timeout=true"; - } - }); - - // in some cases it's necessary to start manually - AccountTimeoutWorker.postMessage({ - method: "start", - data: { - timeout: AccountTimeoutLength, - warnSeconds: AccountTimeoutWarnSeconds, - enabled: AccountTimeoutEnabled, - }, - }); - - Echo.private(`ProcessMaker.Models.User.${user.id}`) - .notification((token) => { - pushNotification(token); - }) - .listen(".SessionStarted", (e) => { - const lifetime = parseInt(eval(e.lifetime)); - if (isSameDevice(e)) { - AccountTimeoutWorker.postMessage({ - method: "start", - data: { - timeout: lifetime, - warnSeconds: AccountTimeoutWarnSeconds, - enabled: AccountTimeoutEnabled, - }, - }); - if (closeSessionModal) { - closeSessionModal(); - } - } - }) - .listen(".Logout", (e) => { - if (isSameDevice(e) && window.location.pathname.indexOf("/logout") === -1) { - const localDeviceId = Vue.$cookies.get(e.device_variable); - const redirectLogoutinterval = setInterval(() => { - const newDeviceId = Vue.$cookies.get(e.device_variable); - if (localDeviceId !== newDeviceId) { - clearInterval(redirectLogoutinterval); - window.location.href = "/logout"; - } - }, 100); - } - }) - .listen(".SecurityLogDownloadJobCompleted", (e) => { - if (e.success) { - const { link } = e; - const { message } = e; - alert(message, "success", 0, false, false, link); - } else { - alert(e.message, "warning"); - } - }); + if (!user) { + return; + } - setGlobalPMVariables({ - AccountTimeoutLength, - AccountTimeoutWarnSeconds, - AccountTimeoutEnabled, - AccountTimeoutWorker, - }); + // Backend provides minutes for lifetime and seconds for warnings. + const accountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content)); + const warnSeconds = parseInt(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); + const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds; + const accountTimeoutEnabled = document.head.querySelector("meta[name=\"timeout-enabled\"]") ? parseInt(document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content) : 1; + + const sessionSyncState = initSessionSync({ + userId: user.id, + isProd, + timeoutScript, + accountTimeoutLength, + accountTimeoutWarnSeconds, + accountTimeoutEnabled, + Vue, + Echo, + pushNotification, + alert, + getSessionModal: () => getGlobalPMVariable("sessionModal"), + getCloseSessionModal: () => getGlobalPMVariable("closeSessionModal"), + getNavbar: () => getGlobalPMVariable("navbar"), + }); + + if (!sessionSyncState) { + return; } + + setGlobalPMVariables({ + AccountTimeoutLength: sessionSyncState.AccountTimeoutLength, + AccountTimeoutWarnSeconds: sessionSyncState.AccountTimeoutWarnSeconds, + AccountTimeoutWarnMinutes: sessionSyncState.AccountTimeoutWarnMinutes, + AccountTimeoutEnabled: sessionSyncState.AccountTimeoutEnabled, + AccountTimeoutWorker: sessionSyncState.AccountTimeoutWorker, + sessionSync: sessionSyncState.sessionSync, + }); }; diff --git a/resources/js/next/layout/navbar.js b/resources/js/next/layout/navbar.js index 21edd620a7..472eea99d7 100644 --- a/resources/js/next/layout/navbar.js +++ b/resources/js/next/layout/navbar.js @@ -63,6 +63,7 @@ const sessionModal = function (title, message, time, warnSeconds) { const closeSessionModal = function () { ProcessMaker.navbar.sessionShow = false; + ProcessMaker.navbar.sessionIsRenewing = false; }; // Set out own specific confirm modal. @@ -137,9 +138,11 @@ const navbar = new Vue({ sessionMessage: "", sessionTime: "", sessionWarnSeconds: "", + sessionIsRenewing: false, taskTitle: "", isMobile: false, isMobileDevice: mobileApp, + isNavbarExpanded: false, }; }, watch: { @@ -213,6 +216,9 @@ const navbar = new Vue({ onResize() { this.isMobile = window.innerWidth < 992; }, + toggleNavbar() { + this.isNavbarExpanded = !this.isNavbarExpanded; + }, }, }); diff --git a/resources/js/next/screenBuilder.js b/resources/js/next/screenBuilder.js index f1253c01dc..d0d009849e 100644 --- a/resources/js/next/screenBuilder.js +++ b/resources/js/next/screenBuilder.js @@ -31,9 +31,13 @@ export default () => { // @link https://processmaker.atlassian.net/browse/FOUR-6833 Cache configuration const screenCacheEnabled = document.head.querySelector("meta[name=\"screen-cache-enabled\"]")?.content ?? "false"; const screenCacheTimeout = document.head.querySelector("meta[name=\"screen-cache-timeout\"]")?.content ?? "5000"; + const screenSecureHandlerToggleVisible = document.head.querySelector("meta[name='screen-secure-handler-toggle-visible']"); + const screenMergeDraftOnRestore = document.head.querySelector("meta[name='screen-merge-draft-on-restore']")?.content ?? "true"; const screen = { cacheEnabled: screenCacheEnabled === "true", cacheTimeout: Number(screenCacheTimeout), + secureHandlerToggleVisible: !!Number(screenSecureHandlerToggleVisible?.content), + mergeDraftOnRestore: screenMergeDraftOnRestore === "true", }; setGlobalVariable("ScreenBuilder", ScreenBuilder); diff --git a/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue b/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue index 40e6c2dacd..2b076d345a 100644 --- a/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue +++ b/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue @@ -28,14 +28,14 @@
@@ -148,6 +148,9 @@ export default { infoCollapsed: true, processEvents: [], singleStartEvent: null, + inProgress: 0, + completed: 0, + total: 0, }; }, computed: { @@ -163,11 +166,25 @@ export default { }, mounted() { this.verifyDescription(); + // Initialize chart data from process.counts + this.inProgress = this.process.counts?.in_progress || 0; + this.completed = this.process.counts?.completed || 0; + this.total = this.process.counts?.total || 0; ProcessMaker.EventBus.$on("reloadByNewScreen", () => { window.location.reload(); }); + ProcessMaker.EventBus.$on("chartDataUpdated", (data) => { + if (data.processId === this.process.id) { + this.completed = data.completed; + this.inProgress = data.inProgress; + this.total = data.total; + } + }); this.getStartEvents(); }, + beforeDestroy() { + ProcessMaker.EventBus.$off("chartDataUpdated"); + }, methods: { /** * Verify if the Description is large diff --git a/resources/js/processes-catalogue/components/ProcessesCatalogue.vue b/resources/js/processes-catalogue/components/ProcessesCatalogue.vue index 79f1fdbed0..95f7115307 100644 --- a/resources/js/processes-catalogue/components/ProcessesCatalogue.vue +++ b/resources/js/processes-catalogue/components/ProcessesCatalogue.vue @@ -7,18 +7,21 @@ :process="selectedProcess ? selectedProcess.name : ''" :template="guidedTemplates ? 'Guided Templates' : ''" /> -
- + - +
@@ -112,6 +112,7 @@ const hookData = async () => { const onPerPage = async (perPage) => { dataPagination.value.perPage = perPage; + dataPagination.value.page = 1; await hookData(); }; diff --git a/resources/js/processes-catalogue/components/optionsMenu/ProcessCounter.vue b/resources/js/processes-catalogue/components/optionsMenu/ProcessCounter.vue index 7b4ffeae16..449e2b674b 100644 --- a/resources/js/processes-catalogue/components/optionsMenu/ProcessCounter.vue +++ b/resources/js/processes-catalogue/components/optionsMenu/ProcessCounter.vue @@ -13,14 +13,14 @@ class="charts" > @@ -38,6 +38,9 @@ export default { data() { return { count: 0, + inProgress: 0, + completed: 0, + total: 0, }; }, computed: { @@ -58,6 +61,27 @@ export default { .catch(() => { this.count = 0; }); + + ProcessMaker.apiClient + .get(`requests/${this.process.id}/default-chart`) + .then((response) => { + const { data: { datasets: { data: [completedCount, inProgressCount] } } } = response.data; + this.completed = completedCount; + this.inProgress = inProgressCount; + this.total = this.completed + this.inProgress; + // Emit event to update other components + ProcessMaker.EventBus.$emit("chartDataUpdated", { + processId: this.process.id, + completed: this.completed, + inProgress: this.inProgress, + total: this.total, + }); + }) + .catch(() => { + this.inProgress = 0; + this.completed = 0; + this.total = 0; + }); }, }, }; diff --git a/resources/js/processes/components/CreateProcessModal.vue b/resources/js/processes/components/CreateProcessModal.vue index 89f5b4c13f..d7347b7ac7 100644 --- a/resources/js/processes/components/CreateProcessModal.vue +++ b/resources/js/processes/components/CreateProcessModal.vue @@ -65,7 +65,8 @@ > @@ -142,7 +143,7 @@ export default { {"content": "Cancel", "action": "hide()", "variant": "outline-secondary", "disabled": false, "hidden": false}, {"content": "Create", "action": "createTemplate", "variant": "primary", "disabled": false, "hidden": false}, ], - manager: "", + manager: [], }; }, watch: { @@ -160,7 +161,7 @@ export default { }, manager() { if (!this.manager) { - this.manager = ""; + this.manager = []; } }, }, @@ -208,7 +209,7 @@ export default { this.addError = {}; this.selectedFile = ""; this.file = null; - this.manager = ""; + this.manager = []; this.$emit("resetModal"); }, onSubmit() { @@ -228,12 +229,13 @@ export default { } this.disabled = true; + let managerIds = this.manager && Array.isArray(this.manager) ? [...this.manager.map(manager => manager.id)] : []; const formData = new FormData(); formData.append("name", this.name); formData.append("description", this.description); formData.append("process_category_id", this.process_category_id); formData.append("projects", this.projects); - formData.append("manager_id", this.manager.id); + formData.append("manager_id", JSON.stringify(managerIds)); if (this.file) { formData.append("file", this.file); } diff --git a/resources/js/processes/designer/RecentAssetsList.vue b/resources/js/processes/designer/RecentAssetsList.vue index 62ffe90dd8..5e0b26bee4 100644 --- a/resources/js/processes/designer/RecentAssetsList.vue +++ b/resources/js/processes/designer/RecentAssetsList.vue @@ -91,6 +91,39 @@ :assetId="assetId" :assetName="assetName" /> + + +
+
+ + + +
+
+ +
+
+ +