From 206b487fa29b691367015c8c63d845eee51d13d4 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 28 Apr 2026 14:21:31 +0200 Subject: [PATCH] Add new table and enhance studio layout logic Introduce `department_selfserve_studio_layouts` table for department-specific layouts and implement advanced auto-layout functionality in the DepartmentSelfServeStudio module. Added custom node definitions, updated styling, and integrated new logics for sorting and visualizing nodes in the Vue Flow interface. --- .../classes/selfserve_schema_bootstrap.php | 12 + .../classes/selfserve_config_versioning.php | 51 +- .../classes/selfserve_studio_graph.php | 1469 +++++++++++++++++ services/nginx/app/openapi.yaml | 398 +++++ .../routes/departmentSelfserveStudioRoute.php | 225 +++ .../app/tests/Api/SelfserveFixtureApiTest.php | 23 + .../Api/SelfserveZZZShellyGuardApiTest.php | 7 + .../SelfserveConfigVersioningTest.php | 18 + .../Selfserve/SelfserveOpenApiSpecTest.php | 16 + .../Selfserve/SelfserveRouteWiringTest.php | 23 + ...fserveSchemaBootstrapCompatibilityTest.php | 9 + .../Selfserve/SelfserveStudioGraphTest.php | 149 ++ .../Selfserve/ShellyRealRequestGuardTest.php | 92 ++ .../ZZZShellyGuardSafetyMetaTest.php | 5 + 14 files changed, 2496 insertions(+), 1 deletion(-) create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php create mode 100644 services/nginx/app/routes/departmentSelfserveStudioRoute.php create mode 100644 services/nginx/app/tests/Api/SelfserveFixtureApiTest.php create mode 100644 services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index 015e1109..a4be70dc 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -114,6 +114,18 @@ class selfserve_schema_bootstrap INDEX idx_selfserve_wash_session_events_session (session_id), INDEX idx_selfserve_wash_session_events_type (event_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + + "CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + user_id INT NULL, + layout_json JSON NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_department_selfserve_studio_layouts_department_user (department_id, user_id), + INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; foreach ($queries as $sql) { diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php index 47d343ef..6a9f54a5 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -273,6 +273,7 @@ class selfserve_config_versioning } $conditionIds = []; + $conditionParents = []; foreach ($conditions as $condition) { $id = (int)($condition['id'] ?? 0); if ($id <= 0) { @@ -281,9 +282,20 @@ class selfserve_config_versioning } $conditionIds[$id] = true; $parentId = $this->nullableInt($condition['condition_id'] ?? null); + $conditionParents[$id] = $parentId; + } + + foreach ($conditionParents as $id => $parentId) { if ($parentId !== null && !isset($conditionIds[$parentId])) { - $warnings[] = 'Condition ' . $id . ' references parent condition ' . $parentId . ' that may be defined later or missing.'; + $errors[] = 'Condition ' . $id . ' references unknown parent condition_id ' . $parentId; } + if ($parentId === $id) { + $errors[] = 'Condition ' . $id . ' cannot reference itself as parent condition.'; + } + } + + foreach ($this->detectConditionCycles($conditionParents) as $cycle) { + $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle); } foreach ($rules as $rule) { @@ -421,4 +433,41 @@ class selfserve_config_versioning $intValue = (int)$value; return $intValue <= 0 ? null : $intValue; } + + /** + * @param array $parents + * @return array> + */ + protected function detectConditionCycles(array $parents): array + { + $cycles = []; + $seenCycleKeys = []; + + foreach (array_keys($parents) as $startId) { + $path = []; + $indexById = []; + $currentId = (int)$startId; + + while ($currentId > 0 && array_key_exists($currentId, $parents)) { + if (isset($indexById[$currentId])) { + $cycle = array_slice($path, $indexById[$currentId]); + $cycle[] = $currentId; + $keyNodes = $cycle; + sort($keyNodes); + $key = implode(':', $keyNodes); + if (!isset($seenCycleKeys[$key])) { + $seenCycleKeys[$key] = true; + $cycles[] = $cycle; + } + break; + } + + $indexById[$currentId] = count($path); + $path[] = $currentId; + $currentId = (int)($parents[$currentId] ?? 0); + } + } + + return $cycles; + } } diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php new file mode 100644 index 00000000..990ba5bd --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -0,0 +1,1469 @@ +> */ + private array $columnCache = []; + + public function __construct() + { + selfserve_schema_bootstrap::ensureTables(); + } + + /** + * @param array $permissions + * @return array + */ + public function buildGraph(int $departmentId, ?int $userId = null, array $permissions = []): array + { + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); + $gatewayWorkspace = ($permissions['modules_shelly_config'] ?? false) + ? $this->buildGatewayWorkspace($departmentId) + : [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => true, + ]; + $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace); + $layout = $this->loadLayout($departmentId, $userId); + $graph = $this->buildGraphFromConfig($config, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ], $layout); + + $validation = $versioning->validateConfig($config); + $validation['items'] = $this->buildValidationItems($validation); + + return [ + 'nodes' => $graph['nodes'], + 'edges' => $graph['edges'], + 'lookups' => $lookups, + 'validation' => $validation, + 'layout' => $layout, + 'versions' => $versioning->listVersions($departmentId), + 'active_config' => $versioning->getPublishedConfig($departmentId), + 'draft' => [ + 'id' => $draft['id'] ?? null, + 'status' => $draft['status'] ?? selfserve_config_versioning::STATUS_DRAFT, + 'version_number' => $draft['version_number'] ?? null, + 'created_at' => $draft['created_at'] ?? null, + 'updated_at' => $draft['updated_at'] ?? null, + ], + 'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups), + 'gateway_workspace' => $gatewayWorkspace, + 'permissions' => $permissions, + 'meta' => [ + 'department_id' => $departmentId, + 'layout_affects_runtime' => false, + 'generated_at' => date('c'), + ], + ]; + } + + /** + * Pure graph builder used by unit tests and the API serializer. + * + * @param array $config + * @param array $context + * @param array $layout + * @return array{nodes:array>,edges:array>} + */ + public function buildGraphFromConfig(array $config, array $context = [], array $layout = []): array + { + $lookups = is_array($context['lookups'] ?? null) ? (array)$context['lookups'] : []; + $gatewayWorkspace = is_array($context['gateway_workspace'] ?? null) ? (array)$context['gateway_workspace'] : []; + $nodes = []; + $edges = []; + + $nodes[] = $this->node('checkpoint:start', 'input', 'Runtime start', 'runtime_checkpoint', [ + 'stage' => 'start', + 'subtitle' => 'Vehicle scanned', + ], 0, 0); + $nodes[] = $this->node('checkpoint:eligible', 'default', 'Eligibility resolved', 'runtime_checkpoint', [ + 'stage' => 'eligible', + 'subtitle' => 'Questions, rules, and gates evaluated', + ], 320, 0); + $nodes[] = $this->node('checkpoint:finish', 'output', 'Wash complete', 'runtime_checkpoint', [ + 'stage' => 'finish', + 'subtitle' => 'Session closed', + ], 640, 0); + $edges[] = $this->edge('runtime:start-eligible', 'checkpoint:start', 'checkpoint:eligible', 'runtime', 'runtime'); + $edges[] = $this->edge('runtime:eligible-finish', 'checkpoint:eligible', 'checkpoint:finish', 'runtime', 'runtime'); + + foreach ($this->lookupRows($lookups, 'lanes') as $index => $lane) { + $id = 'lane:' . (int)($lane['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($lane['label'] ?? ('Lane ' . ($lane['id'] ?? ''))), 'lane', [ + 'object_id' => (int)($lane['id'] ?? 0), + 'raw' => $lane, + 'subtitle' => 'Lane scope', + ], 0, 180 + ($index * 120)); + } + + foreach ($this->lookupRows($lookups, 'machine_types') as $index => $machineType) { + $id = 'machine_type:' . (int)($machineType['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($machineType['label'] ?? ('Machine type ' . ($machineType['id'] ?? ''))), 'machine_type', [ + 'object_id' => (int)($machineType['id'] ?? 0), + 'raw' => $machineType, + 'subtitle' => 'Reusable machine setup', + ], 0, 560 + ($index * 120)); + } + + foreach ($this->lookupRows($lookups, 'vehicle_types') as $index => $vehicleType) { + $id = 'vehicle_type:' . (int)($vehicleType['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($vehicleType['label'] ?? ('Vehicle type ' . ($vehicleType['id'] ?? ''))), 'vehicle_type', [ + 'object_id' => (int)($vehicleType['id'] ?? 0), + 'raw' => $vehicleType, + 'subtitle' => 'Vehicle scope', + ], 0, 880 + ($index * 120)); + } + + foreach ($this->sortedRows((array)($config['conditions'] ?? []), ['name', 'id']) as $index => $condition) { + $id = (int)($condition['id'] ?? 0); + $nodes[] = $this->node('condition:' . $id, 'default', $this->entityLabel('condition', $id, $condition, $lookups), 'condition', [ + 'object_id' => $id, + 'raw' => $condition, + 'scope' => $this->scopeForRow($condition, $lookups), + 'subtitle' => $this->scopeLabel($condition, $lookups), + ], 360, 160 + ($index * 130)); + + $parentId = $this->nullableInt($condition['condition_id'] ?? null); + if ($parentId !== null) { + $edges[] = $this->edge('condition-parent:' . $parentId . ':' . $id, 'condition:' . $parentId, 'condition:' . $id, 'condition_group', 'parent'); + } + $this->appendScopeEdges($edges, 'condition:' . $id, $condition); + } + + foreach ($this->sortedRows((array)($config['questions'] ?? []), ['order_priority', 'id']) as $index => $question) { + $id = (int)($question['id'] ?? 0); + $nodes[] = $this->node('question:' . $id, 'default', $this->entityLabel('question', $id, $question, $lookups), 'question', [ + 'object_id' => $id, + 'raw' => $question, + 'scope' => $this->scopeForRow($question, $lookups), + 'subtitle' => $this->scopeLabel($question, $lookups), + ], 720, 160 + ($index * 130)); + + $conditionId = $this->nullableInt($question['condition_id'] ?? null); + if ($conditionId !== null) { + $edges[] = $this->edge('question-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'question:' . $id, 'visibility_gate', 'show if'); + } + $this->appendScopeEdges($edges, 'question:' . $id, $question); + } + + foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) { + $id = (int)($rule['id'] ?? 0); + $nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [ + 'object_id' => $id, + 'raw' => $rule, + 'subtitle' => $this->ruleSubtitle($rule, $lookups), + ], 520, 520 + ($index * 120)); + + $conditionId = (int)($rule['condition_id'] ?? 0); + if ($conditionId > 0) { + $edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of'); + } + $objectType = strtolower((string)($rule['object_type'] ?? '')); + $objectId = (int)($rule['object_id'] ?? 0); + if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) { + $edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule')); + } + } + + $tasksByScope = []; + foreach ($this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']) as $index => $task) { + $id = (int)($task['id'] ?? 0); + $nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [ + 'object_id' => $id, + 'raw' => $this->normalizeTaskPayload($task), + 'scope' => $this->scopeForRow($task, $lookups), + 'subtitle' => $this->scopeLabel($task, $lookups), + ], 1080, 160 + ($index * 130)); + + $gateType = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + if ($gateType === selfserve_task_gate_type::CONDITION->value && $gateRefId !== null) { + $edges[] = $this->edge('task-gate:condition:' . $gateRefId . ':' . $id, 'condition:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); + } elseif ($gateType === selfserve_task_gate_type::QUESTION->value && $gateRefId !== null) { + $edges[] = $this->edge('task-gate:question:' . $gateRefId . ':' . $id, 'question:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); + } + + $scopeKey = implode(':', [ + (int)($task['department'] ?? 0), + (int)($task['lane'] ?? 0), + (int)($task['product'] ?? 0), + (int)($task['machine_type_id'] ?? 0), + ]); + $tasksByScope[$scopeKey][] = $task; + $this->appendScopeEdges($edges, 'task:' . $id, $task); + } + + foreach ($tasksByScope as $tasks) { + $orderedTasks = $this->sortedRows($tasks, ['order_priority', 'id']); + for ($i = 1; $i < count($orderedTasks); $i++) { + $sourceId = (int)($orderedTasks[$i - 1]['id'] ?? 0); + $targetId = (int)($orderedTasks[$i]['id'] ?? 0); + if ($sourceId > 0 && $targetId > 0) { + $edges[] = $this->edge('task-order:' . $sourceId . ':' . $targetId, 'task:' . $sourceId, 'task:' . $targetId, 'task_order', 'then'); + } + } + } + + $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace); + + return [ + 'nodes' => $this->applyLayoutToNodes($nodes, $layout), + 'edges' => array_values($edges), + ]; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array + { + $operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : []; + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyOperation($departmentId, $operation); + } + } + + if (isset($payload['layout']) && is_array($payload['layout'])) { + $this->saveLayout($departmentId, $userId, (array)$payload['layout']); + } elseif (isset($payload['nodes']) && is_array($payload['nodes'])) { + $this->saveLayout($departmentId, $userId, [ + 'nodes' => $this->extractNodePositions((array)$payload['nodes']), + 'viewport' => is_array($payload['viewport'] ?? null) ? (array)$payload['viewport'] : [], + ]); + } + + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($departmentId); + return $this->buildGraph($departmentId, $userId, $permissions); + } + + /** + * @param array $layout + * @return array + */ + public function saveLayout(int $departmentId, ?int $userId, array $layout): array + { + $normalized = [ + 'nodes' => $this->extractNodePositions((array)($layout['nodes'] ?? [])), + 'viewport' => is_array($layout['viewport'] ?? null) ? (array)$layout['viewport'] : [], + 'saved_at' => date('c'), + 'runtime_affecting' => false, + ]; + $layoutJson = json_encode($normalized, JSON_UNESCAPED_UNICODE); + if ($layoutJson === false) { + throw new \RuntimeException('Failed to encode studio layout JSON: ' . json_last_error_msg()); + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "SELECT id + FROM department_selfserve_studio_layouts + WHERE department_id = :department_id + AND " . ($userId === null ? "user_id IS NULL" : "user_id = :user_id") . " + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1" + ); + $params = [':department_id' => $departmentId]; + if ($userId !== null) { + $params[':user_id'] = $userId; + } + $statement->execute($params); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + + if (is_array($row) && (int)($row['id'] ?? 0) > 0) { + $update = $pdo->prepare( + "UPDATE department_selfserve_studio_layouts + SET layout_json = :layout_json, updated_at = NOW() + WHERE id = :id" + ); + $update->execute([ + ':layout_json' => $layoutJson, + ':id' => (int)$row['id'], + ]); + } else { + $insert = $pdo->prepare( + "INSERT INTO department_selfserve_studio_layouts (department_id, user_id, layout_json) + VALUES (:department_id, :user_id, :layout_json)" + ); + $insert->execute([ + ':department_id' => $departmentId, + ':user_id' => $userId, + ':layout_json' => $layoutJson, + ]); + } + + return $normalized; + } + + /** + * @return array + */ + public function loadLayout(int $departmentId, ?int $userId): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "SELECT layout_json + FROM department_selfserve_studio_layouts + WHERE department_id = :department_id + AND deleted_at IS NULL + AND (user_id = :user_id_filter OR user_id IS NULL) + ORDER BY CASE WHEN user_id = :user_id_sort THEN 0 ELSE 1 END, updated_at DESC, id DESC + LIMIT 1" + ); + $statement->execute([ + ':department_id' => $departmentId, + ':user_id_filter' => $userId, + ':user_id_sort' => $userId, + ]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return [ + 'nodes' => [], + 'viewport' => [], + 'runtime_affecting' => false, + ]; + } + + $layout = json_decode((string)($row['layout_json'] ?? '{}'), true); + if (!is_array($layout)) { + $layout = []; + } + $layout['runtime_affecting'] = false; + return $layout; + } + + /** + * @param array $payload + * @return array + */ + public function validatePayload(int $departmentId, array $payload = []): array + { + $config = (new selfserve_config_versioning())->snapshotLegacyConfig($departmentId); + $validation = (new selfserve_config_versioning())->validateConfig($config); + $validation['items'] = $this->buildValidationItems($validation); + return $validation; + } + + /** + * @param array $payload + * @return array + */ + public function runGatewayAction(int $departmentId, int $gatewayId, string $action, array $payload, ?int $userId): array + { + if (!class_exists(edge_gateway_view_service::class)) { + throw new \RuntimeException('Edge gateway module is not available.'); + } + + $gateway = (new edge_gateway_view_service())->getGateway($gatewayId); + if (!isset($gateway['id'])) { + throw new \RuntimeException('Edge gateway not found.'); + } + if ((int)($gateway['department_id'] ?? 0) !== $departmentId) { + throw new \RuntimeException('Edge gateway does not belong to the selected department.'); + } + + $action = strtolower(trim($action)); + $operations = new edge_gateway_operation_service(); + return match ($action) { + 'discovery', 'discover' => [ + 'action' => 'discovery', + 'operation' => $operations->queueDiscoveryOperation($gatewayId, $userId), + ], + 'update' => [ + 'action' => 'update', + 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UPDATE, (array)($payload['request'] ?? []), $userId), + ], + 'uninstall' => [ + 'action' => 'uninstall', + 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UNINSTALL, (array)($payload['request'] ?? []), $userId), + ], + 'cancel' => [ + 'action' => 'cancel', + 'operation' => $operations->cancelOperation($gatewayId, (int)($payload['operation_id'] ?? 0), $userId), + ], + 'rotate_credentials' => [ + 'action' => 'rotate_credentials', + 'gateway' => $operations->rotateCredentials($gatewayId, $userId), + ], + 'bindings' => [ + 'action' => 'bindings', + 'gateway' => (new edge_gateway_registry_service())->setRelayBindings($gatewayId, (array)($payload['bindings'] ?? []), $userId), + ], + default => throw new \RuntimeException('Unsupported gateway action: ' . $action), + }; + } + + /** + * @return array + */ + private function buildGatewayWorkspace(int $departmentId): array + { + if (!class_exists(edge_gateway_department_workspace_service::class)) { + return [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'available' => false, + ]; + } + + try { + return (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId); + } catch (\Throwable $exception) { + return [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [ + [ + 'severity' => 'warning', + 'message' => $exception->getMessage(), + ], + ], + 'actions' => [], + 'available' => false, + ]; + } + } + + /** + * @param array $config + * @param array $gatewayWorkspace + * @return array + */ + private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array + { + $departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]); + $laneRows = $this->fetchRows('department_lanes', ['id', 'department', 'name', 'machine_type_id'], ['department' => $departmentId]); + $machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []); + $productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []); + $vehicleTypeRows = $this->vehicleTypeRowsFromProducts($productRows); + $users = $this->fetchRows('users', ['id', 'customer_number', 'display_name'], []); + + $lookups = [ + 'departments' => $this->labelRows($departmentRows, 'name'), + 'lanes' => $this->labelRows($laneRows, 'name'), + 'products' => $this->labelRows($productRows, 'name'), + 'machine_types' => $this->labelRows($machineTypeRows, 'name'), + 'vehicle_types' => $vehicleTypeRows, + 'questions' => $this->configLabelRows((array)($config['questions'] ?? []), 'question'), + 'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'), + 'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'), + 'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'), + 'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), + 'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])), + 'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), + 'users' => array_map(static function (array $row): array { + $label = trim((string)($row['display_name'] ?? '')); + if ($label === '') { + $label = 'User ' . (string)($row['customer_number'] ?? $row['id'] ?? ''); + } + $row['label'] = $label; + return $row; + }, $users), + ]; + + $labels = []; + foreach ($lookups as $type => $rows) { + if (!is_array($rows)) { + continue; + } + $labels[$type] = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $id = (string)($row['id'] ?? ''); + if ($id !== '') { + $labels[$type][$id] = (string)($row['label'] ?? $id); + } + } + } + $lookups['labels'] = $labels; + + return $lookups; + } + + /** + * @param array> $nodes + * @param array> $edges + * @param array $workspace + */ + private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void + { + foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = (int)($gateway['id'] ?? 0); + if ($gatewayId <= 0) { + continue; + } + + $nodeId = 'gateway:' . $gatewayId; + $nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [ + 'object_id' => $gatewayId, + 'raw' => $gateway, + 'subtitle' => (string)($gateway['status'] ?? 'UNKNOWN'), + ], 1440, 160 + ($index * 150)); + + foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $bindingId = 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex; + $nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [ + 'object_id' => $relayId, + 'raw' => $binding, + 'subtitle' => (string)($binding['role'] ?? 'Relay binding'), + ], 1720, 180 + (($index * 4 + $bindingIndex) * 100)); + $edges[] = $this->edge('gateway-binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $nodeId, $bindingId, 'gateway_binding', 'binds'); + $edges[] = $this->edge('binding-relay:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $bindingId, 'relay:' . $relayId, 'relay_binding', 'controls'); + } + } + + $relayIndex = 0; + foreach ((array)($workspace['relays'] ?? []) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $nodes[] = $this->node('relay:' . $relayId, 'default', (string)($relay['name'] ?? ('Relay ' . $relayId)), 'relay', [ + 'object_id' => $relayId, + 'raw' => $relay, + 'subtitle' => 'Hardware relay', + ], 2020, 180 + ($relayIndex * 100)); + $relayIndex++; + } + + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + $laneId = (int)($lane['id'] ?? 0); + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($laneId > 0 && $relayId !== '') { + $edges[] = $this->edge('relay-lane:' . $relayId . ':' . $laneId . ':' . (string)($slot['slot'] ?? ''), 'relay:' . $relayId, 'lane:' . $laneId, 'lane_relay', (string)($slot['slot'] ?? 'relay')); + } + } + } + } + + /** + * @param array $operation + */ + private function applyOperation(int $departmentId, array $operation): void + { + $action = strtolower((string)($operation['action'] ?? '')); + $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); + $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : []; + $id = (int)($operation['id'] ?? $data['id'] ?? 0); + + if ($action === 'connect') { + $this->applyConnection((string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); + return; + } + if ($action === 'disconnect') { + $this->applyConnection((string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); + return; + } + if ($action === 'reorder') { + $this->applyReorder($entity, (array)($operation['items'] ?? [])); + return; + } + if ($entity === '') { + throw new \RuntimeException('Studio graph operation is missing entity.'); + } + + if ($action === 'create') { + $this->createEntity($departmentId, $entity, $data); + return; + } + if ($id <= 0) { + throw new \RuntimeException('Studio graph operation is missing id.'); + } + if ($action === 'update') { + $this->updateEntity($departmentId, $entity, $id, $data); + return; + } + if ($action === 'delete') { + $this->softDeleteEntity($departmentId, $entity, $id); + return; + } + + throw new \RuntimeException('Unsupported studio graph operation: ' . $action); + } + + /** + * @param array $data + */ + private function createEntity(int $departmentId, string $entity, array $data): void + { + $pdo = db::getPDO(); + if ($entity === 'question') { + $pdo->prepare( + "INSERT INTO department_selfserve_questions (department, lane, product, condition_id, question, description, order_priority) + VALUES (:department, :lane, :product, :condition_id, :question, :description, :order_priority)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), + ':question' => (string)($data['question'] ?? $data['label'] ?? 'New question'), + ':description' => (string)($data['description'] ?? ''), + ':order_priority' => (int)($data['order_priority'] ?? 0), + ]); + return; + } + if ($entity === 'condition') { + $pdo->prepare( + "INSERT INTO department_selfserve_conditions (department, lane, product, machine_type_id, condition_id, name, description) + VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :name, :description)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), + ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), + ':name' => (string)($data['name'] ?? $data['label'] ?? 'New condition'), + ':description' => (string)($data['description'] ?? ''), + ]); + return; + } + if ($entity === 'rule') { + $conditionId = (int)($data['condition_id'] ?? 0); + if (!$this->conditionBelongsToDepartment($conditionId, $departmentId)) { + throw new \RuntimeException('Rule condition_id is not available in the selected department.'); + } + $pdo->prepare( + "INSERT INTO department_selfserve_condition_rules (condition_id, type, object_type, object_id, name, description) + VALUES (:condition_id, :type, :object_type, :object_id, :name, :description)" + )->execute([ + ':condition_id' => $conditionId, + ':type' => (string)($data['type'] ?? 'IS_TRUE'), + ':object_type' => (string)($data['object_type'] ?? 'question'), + ':object_id' => (int)($data['object_id'] ?? 0), + ':name' => (string)($data['name'] ?? $data['label'] ?? 'New rule'), + ':description' => (string)($data['description'] ?? ''), + ]); + return; + } + if ($entity === 'task') { + $gateType = $this->normalizeGateType((string)($data['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateRefId = $gateType === selfserve_task_gate_type::ALWAYS->value ? null : $this->nullableInt($data['gate_ref_id'] ?? null); + $pdo->prepare( + "INSERT INTO department_selfserve_tasks (department, lane, product, machine_type_id, condition_id, gate_type, gate_ref_id, task, description, order_priority, services, buttons, dynamic_images_vehicle_type) + VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :gate_type, :gate_ref_id, :task, :description, :order_priority, :services, :buttons, :dynamic_images_vehicle_type)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), + ':condition_id' => $gateType === selfserve_task_gate_type::QUESTION->value ? $gateRefId : null, + ':gate_type' => $gateType, + ':gate_ref_id' => $gateRefId, + ':task' => (string)($data['task'] ?? $data['label'] ?? 'New task'), + ':description' => (string)($data['description'] ?? ''), + ':order_priority' => (int)($data['order_priority'] ?? 0), + ':services' => $this->jsonArray($data['services'] ?? []), + ':buttons' => $this->jsonArray($data['buttons'] ?? []), + ':dynamic_images_vehicle_type' => $this->nullableInt($data['dynamic_images_vehicle_type'] ?? null), + ]); + return; + } + + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + + /** + * @param array $data + */ + private function updateEntity(int $departmentId, string $entity, int $id, array $data): void + { + $map = [ + 'question' => [ + 'table' => 'department_selfserve_questions', + 'fields' => ['lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], + 'department' => 'department', + ], + 'condition' => [ + 'table' => 'department_selfserve_conditions', + 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description'], + 'department' => 'department', + ], + 'rule' => [ + 'table' => 'department_selfserve_condition_rules', + 'fields' => ['condition_id', 'type', 'object_type', 'object_id', 'name', 'description'], + 'department' => null, + ], + 'task' => [ + 'table' => 'department_selfserve_tasks', + 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], + 'department' => 'department', + ], + ]; + if (!isset($map[$entity])) { + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + if ($entity === 'rule' && array_key_exists('condition_id', $data) && !$this->conditionBelongsToDepartment((int)$data['condition_id'], $departmentId)) { + throw new \RuntimeException('Rule condition_id is not available in the selected department.'); + } + + $updates = []; + $params = [ + ':id' => $id, + ]; + foreach ($map[$entity]['fields'] as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $updates[] = "`$field` = :$field"; + $value = $data[$field]; + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) { + $value = $this->nullableInt($value); + } elseif (in_array($field, ['services', 'buttons'], true)) { + $value = $this->jsonArray($value); + } elseif ($field === 'gate_type') { + $value = $this->normalizeGateType((string)$value); + } + $params[':' . $field] = $value; + } + + if (isset($data['label'])) { + if ($entity === 'question' && !isset($data['question'])) { + $updates[] = '`question` = :label'; + $params[':label'] = (string)$data['label']; + } elseif ($entity === 'condition' && !isset($data['name'])) { + $updates[] = '`name` = :label'; + $params[':label'] = (string)$data['label']; + } elseif ($entity === 'task' && !isset($data['task'])) { + $updates[] = '`task` = :label'; + $params[':label'] = (string)$data['label']; + } + } + + if ($updates === []) { + return; + } + + $where = 'id = :id'; + if ($entity === 'rule') { + $where .= " AND condition_id IN ( + SELECT id + FROM department_selfserve_conditions + WHERE department IN (0, :department) + AND deleted_at IS NULL + )"; + $params[':department'] = $departmentId; + } elseif ($map[$entity]['department'] !== null) { + $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; + $params[':department'] = $departmentId; + } + db::getPDO()->prepare( + 'UPDATE `' . $map[$entity]['table'] . '` SET ' . implode(', ', $updates) . ' WHERE ' . $where + )->execute($params); + } + + private function softDeleteEntity(int $departmentId, string $entity, int $id): void + { + $map = [ + 'question' => ['table' => 'department_selfserve_questions', 'department' => 'department'], + 'condition' => ['table' => 'department_selfserve_conditions', 'department' => 'department'], + 'rule' => ['table' => 'department_selfserve_condition_rules', 'department' => null], + 'task' => ['table' => 'department_selfserve_tasks', 'department' => 'department'], + ]; + if (!isset($map[$entity])) { + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + + $params = [ + ':id' => $id, + ]; + $where = 'id = :id'; + if ($entity === 'rule') { + $where .= " AND condition_id IN ( + SELECT id + FROM department_selfserve_conditions + WHERE department IN (0, :department) + AND deleted_at IS NULL + )"; + $params[':department'] = $departmentId; + } elseif ($map[$entity]['department'] !== null) { + $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; + $params[':department'] = $departmentId; + } + db::getPDO()->prepare( + 'UPDATE `' . $map[$entity]['table'] . '` SET deleted_at = NOW() WHERE ' . $where + )->execute($params); + } + + /** + * @param array> $items + */ + private function applyReorder(string $entity, array $items): void + { + $table = match ($entity) { + 'question' => 'department_selfserve_questions', + 'task' => 'department_selfserve_tasks', + default => null, + }; + if ($table === null) { + throw new \RuntimeException('Only questions and tasks can be reordered.'); + } + + $statement = db::getPDO()->prepare('UPDATE `' . $table . '` SET order_priority = :order_priority WHERE id = :id'); + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + $statement->execute([ + ':id' => (int)($item['id'] ?? 0), + ':order_priority' => (int)($item['order_priority'] ?? $index), + ]); + } + } + + private function applyConnection(string $source, string $target, bool $disconnect): void + { + [$sourceType, $sourceId] = $this->parseNodeId($source); + [$targetType, $targetId] = $this->parseNodeId($target); + if ($sourceType === '' || $targetType === '' || $sourceId === '') { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if ($sourceType === 'condition' && $targetType === 'question') { + db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? null : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if ($sourceType === 'condition' && $targetType === 'condition') { + db::getPDO()->prepare('UPDATE department_selfserve_conditions SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? null : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { + db::getPDO()->prepare('UPDATE department_selfserve_tasks SET gate_type = :gate_type, gate_ref_id = :gate_ref_id, condition_id = :legacy_question_id WHERE id = :id')->execute([ + ':gate_type' => $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType), + ':gate_ref_id' => $disconnect ? null : (int)$sourceId, + ':legacy_question_id' => (!$disconnect && $sourceType === 'question') ? (int)$sourceId : null, + ':id' => (int)$targetId, + ]); + return; + } + if ($sourceType === 'condition' && $targetType === 'rule') { + db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? 0 : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if (in_array($sourceType, ['question', 'condition', 'task'], true) && $targetType === 'rule') { + db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET object_type = :object_type, object_id = :object_id WHERE id = :id')->execute([ + ':object_type' => $disconnect ? '' : $sourceType, + ':object_id' => $disconnect ? 0 : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + } + + /** + * @param array $row + * @param array $lookups + * @return array + */ + private function scopeForRow(array $row, array $lookups): array + { + return [ + 'department' => $this->labelFor('departments', $row['department'] ?? null, $lookups), + 'lane' => $this->labelFor('lanes', $row['lane'] ?? null, $lookups), + 'product' => $this->labelFor('products', $row['product'] ?? null, $lookups), + 'machine_type' => $this->labelFor('machine_types', $row['machine_type_id'] ?? null, $lookups), + ]; + } + + /** + * @param array $row + */ + private function scopeLabel(array $row, array $lookups): string + { + $parts = []; + foreach (['lane' => 'lanes', 'product' => 'products', 'machine_type_id' => 'machine_types'] as $field => $lookupType) { + $value = $this->nullableInt($row[$field] ?? null); + if ($value !== null) { + $parts[] = $this->labelFor($lookupType, $value, $lookups); + } + } + + return $parts === [] ? 'Shared scope' : implode(' / ', $parts); + } + + /** + * @param array $row + */ + private function ruleSubtitle(array $row, array $lookups): string + { + $objectType = strtolower((string)($row['object_type'] ?? 'object')); + $objectId = (int)($row['object_id'] ?? 0); + $lookupType = $objectType . 's'; + $label = $objectId > 0 ? $this->labelFor($lookupType, $objectId, $lookups) : 'Unbound object'; + return strtoupper((string)($row['type'] ?? 'RULE')) . ' ' . $label; + } + + /** + * @param array> $edges + * @param array $row + */ + private function appendScopeEdges(array &$edges, string $targetId, array $row): void + { + $scopes = [ + 'lane' => 'lane', + 'product' => 'vehicle_type', + 'machine_type_id' => 'machine_type', + ]; + foreach ($scopes as $field => $type) { + $scopeId = $this->nullableInt($row[$field] ?? null); + if ($scopeId !== null) { + $edges[] = $this->edge('scope:' . $type . ':' . $scopeId . ':' . $targetId, $type . ':' . $scopeId, $targetId, 'scope', 'scope'); + } + } + } + + /** + * @param array $layout + * @param array> $nodes + * @return array> + */ + private function applyLayoutToNodes(array $nodes, array $layout): array + { + $positions = $this->extractNodePositions((array)($layout['nodes'] ?? [])); + foreach ($nodes as &$node) { + $id = (string)($node['id'] ?? ''); + if (isset($positions[$id])) { + $node['position'] = $positions[$id]; + } + } + unset($node); + return array_values($nodes); + } + + /** + * @param array $nodes + * @return array + */ + private function extractNodePositions(array $nodes): array + { + $positions = []; + foreach ($nodes as $key => $node) { + if (is_array($node) && isset($node['id'], $node['position']) && is_array($node['position'])) { + $positions[(string)$node['id']] = [ + 'x' => (float)($node['position']['x'] ?? 0), + 'y' => (float)($node['position']['y'] ?? 0), + ]; + continue; + } + if (is_string($key) && is_array($node)) { + $positions[$key] = [ + 'x' => (float)($node['x'] ?? $node['position']['x'] ?? 0), + 'y' => (float)($node['y'] ?? $node['position']['y'] ?? 0), + ]; + } + } + + return $positions; + } + + /** + * @param array $validation + * @return array> + */ + private function buildValidationItems(array $validation): array + { + $items = []; + foreach ((array)($validation['errors'] ?? []) as $message) { + $items[] = [ + 'severity' => 'error', + 'message' => (string)$message, + ]; + } + foreach ((array)($validation['warnings'] ?? []) as $message) { + $items[] = [ + 'severity' => 'warning', + 'message' => (string)$message, + ]; + } + return $items; + } + + /** + * @param array $lookups + * @return array + */ + private function buildSimulatorDefaults(int $departmentId, array $lookups): array + { + $lane = $this->lookupRows($lookups, 'lanes')[0] ?? null; + $vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null; + return [ + 'department' => $departmentId, + 'lane_id' => is_array($lane) ? (int)($lane['id'] ?? 0) : null, + 'vehicle_type_id' => is_array($vehicleType) ? (int)($vehicleType['id'] ?? 0) : null, + 'reg' => 'TEST123', + 'customer_number' => null, + ]; + } + + /** + * @param array $data + * @return array + */ + private function node(string $id, string $type, string $label, string $kind, array $data, int $x, int $y): array + { + $data['kind'] = $kind; + $data['label'] = $label; + return [ + 'id' => $id, + 'type' => $type, + 'position' => [ + 'x' => $x, + 'y' => $y, + ], + 'data' => $data, + ]; + } + + /** + * @return array + */ + private function edge(string $id, string $source, string $target, string $kind, string $label): array + { + return [ + 'id' => $id, + 'source' => $source, + 'target' => $target, + 'type' => 'smoothstep', + 'label' => $label, + 'data' => [ + 'kind' => $kind, + ], + ]; + } + + /** + * @param array $row + * @param array $lookups + */ + private function entityLabel(string $entity, int $id, array $row, array $lookups): string + { + $field = match ($entity) { + 'question' => 'question', + 'condition', 'rule' => 'name', + 'task' => 'task', + default => 'label', + }; + $label = trim((string)($row[$field] ?? '')); + if ($label !== '') { + return $label; + } + return $this->labelFor($entity . 's', $id, $lookups); + } + + /** + * @param array $lookups + */ + private function labelFor(string $lookupType, mixed $id, array $lookups): string + { + $id = $this->nullableInt($id); + if ($id === null) { + return 'All'; + } + $labels = is_array($lookups['labels'][$lookupType] ?? null) ? (array)$lookups['labels'][$lookupType] : []; + return (string)($labels[(string)$id] ?? ucfirst(str_replace('_', ' ', rtrim($lookupType, 's'))) . ' ' . $id); + } + + /** + * @param array $lookups + * @return array> + */ + private function lookupRows(array $lookups, string $type): array + { + return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : []; + } + + /** + * @param array> $rows + * @return array> + */ + private function sortedRows(array $rows, array $fields): array + { + usort($rows, static function (array $left, array $right) use ($fields): int { + foreach ($fields as $field) { + $leftValue = $left[$field] ?? null; + $rightValue = $right[$field] ?? null; + if (is_numeric($leftValue) && is_numeric($rightValue)) { + $comparison = (int)$leftValue <=> (int)$rightValue; + } else { + $comparison = strcmp((string)$leftValue, (string)$rightValue); + } + if ($comparison !== 0) { + return $comparison; + } + } + return 0; + }); + return array_values($rows); + } + + /** + * @param array $task + * @return array + */ + private function normalizeTaskPayload(array $task): array + { + foreach (['services', 'buttons'] as $field) { + if (isset($task[$field]) && is_string($task[$field])) { + $decoded = json_decode((string)$task[$field], true); + $task[$field] = is_array($decoded) ? $decoded : []; + } + } + return $task; + } + + /** + * @param array> $rows + * @return array> + */ + private function vehicleTypeRowsFromProducts(array $rows): array + { + $vehicleTypes = []; + foreach ($this->labelRows($rows, 'name') as $row) { + $productId = (int)($row['id'] ?? 0); + if ($productId <= 0 || (int)($row['is_wash'] ?? 0) !== 1 || (int)($row['subscription_allowed'] ?? 0) !== 1) { + continue; + } + + $row['id'] = $productId; + $row['product'] = $productId; + $row['product_id'] = $productId; + $row['source'] = 'products'; + $vehicleTypes[] = $row; + } + + return $vehicleTypes; + } + + /** + * @param array> $rows + * @return array> + */ + private function labelRows(array $rows, string $labelField): array + { + return array_map(static function (array $row) use ($labelField): array { + $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); + return $row; + }, $rows); + } + + /** + * @param array> $rows + * @return array> + */ + private function configLabelRows(array $rows, string $labelField): array + { + return array_map(static function (array $row) use ($labelField): array { + $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); + return [ + 'id' => (int)($row['id'] ?? 0), + 'label' => $row['label'], + 'raw' => $row, + ]; + }, $rows); + } + + /** + * @param array> $gateways + * @return array> + */ + private function gatewayLabelRows(array $gateways): array + { + $rows = []; + foreach ($gateways as $gateway) { + if (is_array($gateway) && (int)($gateway['id'] ?? 0) > 0) { + $rows[] = [ + 'id' => (int)$gateway['id'], + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])), + 'status' => (string)($gateway['status'] ?? 'UNKNOWN'), + ]; + } + } + return $rows; + } + + /** + * @param array> $relays + * @return array> + */ + private function relayLabelRows(array $relays): array + { + $rows = []; + foreach ($relays as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $rows[] = [ + 'id' => $relayId, + 'label' => (string)($relay['name'] ?? ('Relay ' . $relayId)), + 'raw' => $relay, + ]; + } + return $rows; + } + + /** + * @param array> $gateways + * @return array> + */ + private function bindingLabelRows(array $gateways): array + { + $rows = []; + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = (int)($gateway['id'] ?? 0); + foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($gatewayId <= 0 || $relayId === '') { + continue; + } + $rows[] = [ + 'id' => $gatewayId . ':' . $relayId . ':' . $index, + 'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)), + 'gateway_id' => $gatewayId, + 'relay_id' => $relayId, + ]; + } + } + return $rows; + } + + /** + * @return array> + */ + private function fetchRows(string $table, array $columns, array $where): array + { + if (!$this->tableExists($table)) { + return []; + } + $availableColumns = $this->tableColumns($table); + $columns = array_values(array_filter($columns, static fn(string $column): bool => in_array($column, $availableColumns, true))); + if ($columns === []) { + return []; + } + + $conditions = []; + $params = []; + foreach ($where as $field => $value) { + if (!in_array($field, $availableColumns, true)) { + continue; + } + $conditions[] = '`' . $field . '` = :' . $field; + $params[':' . $field] = $value; + } + if (in_array('deleted_at', $availableColumns, true)) { + $conditions[] = '`deleted_at` IS NULL'; + } + $sql = 'SELECT `' . implode('`, `', $columns) . '` FROM `' . $table . '`'; + if ($conditions !== []) { + $sql .= ' WHERE ' . implode(' AND ', $conditions); + } + if (in_array('order_priority', $availableColumns, true)) { + $sql .= ' ORDER BY `order_priority` ASC, `id` ASC'; + } elseif (in_array('id', $availableColumns, true)) { + $sql .= ' ORDER BY `id` ASC'; + } + + $statement = db::getPDO()->prepare($sql); + $statement->execute($params); + return $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + private function tableExists(string $table): bool + { + $statement = db::getPDO()->prepare( + 'SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' + ); + $statement->execute([':table' => $table]); + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + /** + * @return array + */ + private function tableColumns(string $table): array + { + if (isset($this->columnCache[$table])) { + return $this->columnCache[$table]; + } + $statement = db::getPDO()->prepare( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' + ); + $statement->execute([':table' => $table]); + $this->columnCache[$table] = array_map( + static fn(array $row): string => (string)$row['COLUMN_NAME'], + $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [] + ); + return $this->columnCache[$table]; + } + + private function normalizeEntity(string $entity): string + { + $entity = strtolower(trim($entity)); + return match ($entity) { + 'questions' => 'question', + 'conditions' => 'condition', + 'rules' => 'rule', + 'tasks' => 'task', + default => $entity, + }; + } + + private function normalizeGateType(string $gateType): string + { + $gateType = strtoupper(trim($gateType)); + return in_array($gateType, [ + selfserve_task_gate_type::ALWAYS->value, + selfserve_task_gate_type::CONDITION->value, + selfserve_task_gate_type::QUESTION->value, + ], true) ? $gateType : selfserve_task_gate_type::ALWAYS->value; + } + + private function conditionBelongsToDepartment(int $conditionId, int $departmentId): bool + { + if ($conditionId <= 0) { + return false; + } + + $statement = db::getPDO()->prepare( + "SELECT COUNT(*) AS c + FROM department_selfserve_conditions + WHERE id = :id + AND department IN (0, :department) + AND deleted_at IS NULL" + ); + $statement->execute([ + ':id' => $conditionId, + ':department' => $departmentId, + ]); + + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + /** + * @return array{0:string,1:string} + */ + private function parseNodeId(string $nodeId): array + { + $parts = explode(':', $nodeId, 2); + return [ + strtolower((string)($parts[0] ?? '')), + (string)($parts[1] ?? ''), + ]; + } + + private function jsonArray(mixed $value): string + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value))); + } + if (!is_array($value)) { + $value = []; + } + $json = json_encode(array_values($value), JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new \RuntimeException('Failed to encode JSON array: ' . json_last_error_msg()); + } + return $json; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 74fc9184..14e22b6d 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -5198,6 +5198,207 @@ paths: '404': $ref: '#/components/responses/NotFound' + /department/selfserve/studio/graph: + get: + tags: + - Self-Serve + summary: Get all-in-one self-serve studio graph + description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products. + operationId: getSelfserveStudioGraph + parameters: + - name: department + in: query + required: true + schema: + type: integer + responses: + '200': + description: Studio graph returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + put: + tags: + - Self-Serve + summary: Bulk save all-in-one self-serve studio graph changes + description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior. + operationId: saveSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraphSaveRequest' + responses: + '200': + description: Studio graph saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/layout: + put: + tags: + - Self-Serve + summary: Save self-serve studio canvas layout + description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior. + operationId: saveSelfserveStudioLayout + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest' + responses: + '200': + description: Layout saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayout' + + /department/selfserve/studio/validate: + post: + tags: + - Self-Serve + summary: Validate self-serve studio graph + operationId: validateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: + type: integer + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioValidation' + + /department/selfserve/studio/simulate: + post: + tags: + - Self-Serve + summary: Simulate self-serve studio runtime + operationId: simulateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, lane_id, reg] + properties: + department: { type: integer } + lane_id: { type: integer } + reg: { type: string } + customer_number: { type: integer, nullable: true } + vehicle_type_id: { type: integer, nullable: true } + responses: + '200': + description: Simulator result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + + /department/selfserve/studio/publish: + post: + tags: + - Self-Serve + summary: Publish self-serve studio draft + operationId: publishSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: { type: integer } + responses: + '200': + description: Published version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/rollback: + post: + tags: + - Self-Serve + summary: Roll back self-serve studio to an earlier version + operationId: rollbackSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, target_version_id] + properties: + department: { type: integer } + target_version_id: { type: integer } + responses: + '200': + description: Rollback version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/gateway-action: + post: + tags: + - Self-Serve + summary: Run permission-gated edge gateway action from studio + operationId: runSelfserveStudioGatewayAction + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, gateway_id, action] + properties: + department: { type: integer } + gateway_id: { type: integer } + action: + type: string + enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings] + confirm: + type: boolean + description: Required for dangerous gateway actions such as uninstall and credential rotation. + operation_id: { type: integer, nullable: true } + request: + type: object + additionalProperties: true + bindings: + type: array + items: + type: object + additionalProperties: true + responses: + '200': + description: Gateway action result + content: + application/json: + schema: + type: object + additionalProperties: true + # Products Endpoints /products: get: @@ -14705,6 +14906,203 @@ components: enum: - MACHINE + SelfserveStudioNode: + type: object + required: [id, position, data] + properties: + id: + type: string + type: + type: string + nullable: true + position: + type: object + required: [x, y] + properties: + x: { type: number } + y: { type: number } + data: + type: object + additionalProperties: true + properties: + kind: + type: string + enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint] + object_id: + oneOf: + - type: integer + - type: string + nullable: true + label: + type: string + raw: + type: object + additionalProperties: true + SelfserveStudioEdge: + type: object + required: [id, source, target] + properties: + id: { type: string } + source: { type: string } + target: { type: string } + type: { type: string, nullable: true } + label: { type: string, nullable: true } + data: + type: object + additionalProperties: true + SelfserveStudioLayout: + type: object + properties: + nodes: + type: object + additionalProperties: + type: object + properties: + x: { type: number } + y: { type: number } + viewport: + type: object + additionalProperties: true + runtime_affecting: + type: boolean + enum: [false] + SelfserveStudioValidation: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: { type: string } + warnings: + type: array + items: { type: string } + items: + type: array + items: + type: object + properties: + severity: + type: string + enum: [error, warning] + message: + type: string + stats: + type: object + additionalProperties: true + validated_at: + type: string + format: date-time + SelfserveConfigVersion: + type: object + properties: + id: { type: integer } + department_id: { type: integer } + status: + type: string + enum: [DRAFT, PUBLISHED, ARCHIVED] + version_number: { type: integer } + config: + type: object + additionalProperties: true + validation_result: + $ref: '#/components/schemas/SelfserveStudioValidation' + source_version_id: { type: integer, nullable: true } + created_by: { type: integer, nullable: true } + published_at: { type: string, nullable: true } + created_at: { type: string, nullable: true } + updated_at: { type: string, nullable: true } + SelfserveStudioGraph: + type: object + required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions] + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + edges: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioEdge' + lookups: + type: object + additionalProperties: true + validation: + $ref: '#/components/schemas/SelfserveStudioValidation' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + versions: + type: array + items: + $ref: '#/components/schemas/SelfserveConfigVersion' + active_config: + type: object + nullable: true + additionalProperties: true + draft: + type: object + additionalProperties: true + simulator_defaults: + type: object + additionalProperties: true + gateway_workspace: + type: object + additionalProperties: true + permissions: + type: object + additionalProperties: + type: boolean + meta: + type: object + additionalProperties: true + SelfserveStudioGraphOperation: + type: object + properties: + action: + type: string + enum: [create, update, delete, connect, disconnect, reorder] + entity: + type: string + enum: [question, condition, rule, task] + id: + type: integer + nullable: true + source: + type: string + nullable: true + target: + type: string + nullable: true + data: + type: object + additionalProperties: true + items: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioGraphSaveRequest: + type: object + required: [department] + properties: + department: { type: integer } + operations: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioGraphOperation' + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + SelfserveStudioLayoutSaveRequest: + type: object + required: [department, layout] + properties: + department: { type: integer } + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' SelfserveMachineType: type: object properties: diff --git a/services/nginx/app/routes/departmentSelfserveStudioRoute.php b/services/nginx/app/routes/departmentSelfserveStudioRoute.php new file mode 100644 index 00000000..857e3ecb --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveStudioRoute.php @@ -0,0 +1,225 @@ +get('/department/selfserve/studio/graph', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId, ['view_all_department_selfserve_config_versions']); + + $service = new selfserve_studio_graph(); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GET_STUDIO_GRAPH', 'Fetched self-serve studio graph'); + $response->success($service->buildGraph($departmentId, (int)$user->id, $this->studioPermissions())); + }, [ + 'list_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph', + 'view_all_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph across departments', + ]); + + $this->put('/department/selfserve/studio/graph', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $service = new selfserve_studio_graph(); + $graph = $service->applyGraphSave($departmentId, $payload, (int)$user->id, $this->studioPermissions()); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_GRAPH', 'Saved self-serve studio graph'); + $response->success($graph); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Create, update, delete, connect, and reorder self-serve studio graph objects', + ]); + + $this->put('/department/selfserve/studio/layout', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'layout']); + self::requireType(self::getParameter('layout'), self::TYPE_ARRAY()); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $layout = (new selfserve_studio_graph())->saveLayout($departmentId, (int)$user->id, (array)self::getParameter('layout')); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_LAYOUT', 'Saved self-serve studio layout'); + $response->success($layout); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout', + ]); + + $this->post('/department/selfserve/studio/validate', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $validation = (new selfserve_studio_graph())->validatePayload($departmentId, self::getParametersAsArray()); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'VALIDATE_STUDIO_GRAPH', 'Validated self-serve studio graph'); + $response->success($validation); + }, [ + 'edit_department_selfserve_config_versions' => 'Validate the self-serve studio graph', + ]); + + $this->post('/department/selfserve/studio/simulate', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department', 'lane_id', 'reg']); + $departmentId = (int)self::getParameter('department'); + $laneId = (int)self::getParameter('lane_id'); + self::requireParameterIntPositive($laneId, 'lane_id'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $result = (new selfserve_wash_flow())->previewVehicleEligibility( + $laneId, + (string)self::getParameter('reg'), + self::isParametersSet(['customer_number']) ? (int)self::getParameter('customer_number') : null, + self::isParametersSet(['vehicle_type_id']) ? (int)self::getParameter('vehicle_type_id') : null + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SIMULATE_STUDIO_GRAPH', 'Simulated self-serve studio graph'); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator', + ]); + + $this->post('/department/selfserve/studio/publish', function (): void { + global $response; + $user = $this->requireStudioUser('publish_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph'); + $response->success($published); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'publish_department_selfserve_config_versions' => 'Publish the self-serve studio draft', + ]); + + $this->post('/department/selfserve/studio/rollback', function (): void { + global $response; + $user = $this->requireStudioUser('rollback_department_selfserve_config_versions'); + self::requireParameters(['department', 'target_version_id']); + $departmentId = (int)self::getParameter('department'); + $targetVersionId = (int)self::getParameter('target_version_id'); + self::requireParameterIntPositive($targetVersionId, 'target_version_id'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $rolledBack = (new selfserve_config_versioning())->rollbackToVersion($departmentId, $targetVersionId, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'ROLLBACK_STUDIO_GRAPH', 'Rolled back self-serve studio graph'); + $response->success($rolledBack); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'rollback_department_selfserve_config_versions' => 'Rollback the self-serve studio draft to an earlier version', + ]); + + $this->post('/department/selfserve/studio/gateway-action', function (): void { + global $response; + $user = $this->requireStudioUser('modules_shelly_config'); + self::requireParameters(['department', 'gateway_id', 'action']); + $departmentId = (int)self::getParameter('department'); + $gatewayId = (int)self::getParameter('gateway_id'); + self::requireParameterIntPositive($gatewayId, 'gateway_id'); + $this->assertDepartmentAccess($user, $departmentId); + + $action = strtolower((string)self::getParameter('action')); + $confirmed = filter_var(self::getParameter('confirm'), FILTER_VALIDATE_BOOLEAN); + if (in_array($action, ['uninstall', 'rotate_credentials'], true) && $confirmed !== true) { + $response->error('This gateway action requires explicit confirmation.', 428); + } + + try { + $payload = self::getParametersAsArray(); + $result = (new selfserve_studio_graph())->runGatewayAction($departmentId, $gatewayId, $action, $payload, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GATEWAY_STUDIO_ACTION', 'Ran self-serve studio gateway action: ' . $action); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'modules_shelly_config' => 'Run permission-gated self-serve studio edge gateway actions', + ]); + } + + private function requireStudioUser(string $permission): object + { + global $response; + $this->requirePermission($permission); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + return $user; + } + + /** + * @param array $bypassPermissions + */ + private function assertDepartmentAccess(object $user, int $departmentId, array $bypassPermissions = []): void + { + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (in_array($departmentId, $authorizedDepartmentIds, true)) { + return; + } + + foreach ($bypassPermissions as $permission) { + if ($this->hasPermission($permission)) { + return; + } + } + + $this->forbidDepartmentAccess($departmentId, $bypassPermissions); + } + + /** + * @return array + */ + private function studioPermissions(): array + { + return [ + 'can_view' => $this->hasPermission('list_department_selfserve_config_versions'), + 'can_edit' => $this->hasPermission('edit_department_selfserve_config_versions'), + 'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'), + 'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'), + 'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'), + 'modules_shelly_config' => $this->hasPermission('modules_shelly_config'), + 'can_manage_gateways' => $this->hasPermission('modules_shelly_config'), + 'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'), + 'can_run_live_lane_actions' => $this->hasPermission('modules_selfserve_sessions_force_stop'), + ]; + } +} diff --git a/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php new file mode 100644 index 00000000..a83ee482 --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php @@ -0,0 +1,23 @@ +createSelfServeScenario(); + + expect($scenario['relay_ids']['machine'])->toStartWith('demo-') + ->and($scenario['relay_ids']['entry'])->toStartWith('demo-') + ->and($scenario['lane']['relay_machine_id'])->toStartWith('demo-') + ->and($scenario['session']['status'])->toBe('MACHINE_STARTED') + ->and($scenario['session']['metadata_json']['relay_ids']['machine'])->toBe($scenario['relay_ids']['machine']) + ->and($scenario['tasks'])->toHaveCount(2) + ->and($scenario['events'])->toHaveCount(3); + + $lane = api_fixtures()->fetchRowById('department_lanes', (int)$scenario['lane']['id']); + $session = api_fixtures()->fetchRowById('selfserve_wash_sessions', (int)$scenario['session']['id']); + + expect($lane)->not->toBeNull() + ->and($lane['relay_machine_id'])->toBe($scenario['relay_ids']['machine']) + ->and($session)->not->toBeNull() + ->and($session['reg'])->toBe($scenario['vehicle']['reg']); +}); diff --git a/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php b/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php new file mode 100644 index 00000000..5100c0de --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php @@ -0,0 +1,7 @@ +toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php index 7740b01b..a41a4a58 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php @@ -82,6 +82,24 @@ it('fails validation when typed task gates reference unknown entities', function expect(implode("\n", $validation['errors']))->toContain('requires gate_ref_id'); }); +it('fails validation when nested conditions form cycles', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [], + 'conditions' => [ + ['id' => 10, 'condition_id' => 12], + ['id' => 11, 'condition_id' => 10], + ['id' => 12, 'condition_id' => 11], + ], + 'rules' => [], + 'tasks' => [], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected'); +}); + it('encodes config json payloads with apostrophes before persistence', function (): void { $version = new class extends selfserve_config_versions_o { /** @var array */ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 9732af21..d1cf7b3d 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -64,6 +64,22 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($summaryPathBlock)->toContain('name: vehicle_type'); }); +it('documents the all-in-one self-serve studio replacement API', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('/department/selfserve/studio/graph:'); + expect($content)->toContain('/department/selfserve/studio/layout:'); + expect($content)->toContain('/department/selfserve/studio/validate:'); + expect($content)->toContain('/department/selfserve/studio/simulate:'); + expect($content)->toContain('/department/selfserve/studio/publish:'); + expect($content)->toContain('/department/selfserve/studio/rollback:'); + expect($content)->toContain('/department/selfserve/studio/gateway-action:'); + expect($content)->toContain('SelfserveStudioGraph:'); + expect($content)->toContain('SelfserveStudioGraphSaveRequest:'); + expect($content)->toContain('SelfserveStudioLayout:'); + expect($content)->toContain('runSelfserveStudioGatewayAction'); +}); + it('defines reusable self-serve wash and machine type schemas', function (): void { $content = selfserve_openapi_content_or_skip(); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 86bcd044..e7bdb444 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -47,6 +47,29 @@ it('wires self-serve config draft/publish/rollback lifecycle endpoints', functio expect($configRoute)->toContain('rollback_department_selfserve_config_versions'); }); +it('wires the all-in-one self-serve studio replacement endpoints', function (): void { + $studioRoute = file_get_contents(app_path('routes/departmentSelfserveStudioRoute.php')); + $studioGraph = file_get_contents(app_path('modules/selfserve/classes/selfserve_studio_graph.php')); + + expect($studioRoute)->not->toBeFalse(); + expect($studioRoute)->toContain('/department/selfserve/studio/graph'); + expect($studioRoute)->toContain('/department/selfserve/studio/layout'); + expect($studioRoute)->toContain('/department/selfserve/studio/validate'); + expect($studioRoute)->toContain('/department/selfserve/studio/simulate'); + expect($studioRoute)->toContain('/department/selfserve/studio/publish'); + expect($studioRoute)->toContain('/department/selfserve/studio/rollback'); + expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action'); + expect($studioRoute)->toContain('modules_shelly_config'); + expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop'); + + expect($studioGraph)->not->toBeFalse(); + expect($studioGraph)->toContain('department_selfserve_studio_layouts'); + expect($studioGraph)->toContain('buildGatewayWorkspace'); + expect($studioGraph)->toContain('runGatewayAction'); + expect($studioGraph)->toContain('layout_affects_runtime'); + expect($studioGraph)->toContain('resolved'); +}); + it('wires machine wash included minutes into self-serve module config', function (): void { $selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php')); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php index 012894a8..f56ab834 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -17,3 +17,12 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func expect($bootstrapContent)->toContain("'wash_started_at'"); expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'); }); + +it('creates canvas-only self-serve studio layout storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts'); + expect($bootstrapContent)->toContain('layout_json JSON NOT NULL'); + expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php new file mode 100644 index 00000000..b27ac79d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -0,0 +1,149 @@ +newInstanceWithoutConstructor(); +} + +it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [ + ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => 10, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + 'conditions' => [ + ['id' => 10, 'name' => 'Trailer present', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2], + ], + 'rules' => [ + ['id' => 20, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + ], [ + 'lookups' => [ + 'departments' => [['id' => 2, 'label' => 'Roskilde']], + 'lanes' => [['id' => 7, 'label' => 'Lane 7']], + 'products' => [['id' => 3, 'label' => 'Forvogn']], + 'machine_types' => [], + 'vehicle_types' => [['id' => 3, 'product' => 3, 'label' => 'Forvogn', 'source' => 'products']], + 'labels' => [ + 'departments' => ['2' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'products' => ['3' => 'Forvogn'], + 'vehicle_types' => ['3' => 'Forvogn'], + 'questions' => ['1' => 'Are mirrors folded?'], + 'conditions' => ['10' => 'Trailer present'], + 'tasks' => ['30' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 50, + 'label' => 'Gateway A', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'relay-1', 'label' => 'Machine relay'], + ], + ], + ], + 'relays' => [ + ['relay_id' => 'relay-1', 'name' => 'Machine relay'], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'relay-1', 'slot' => 'MACHINE'], + ], + ], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + + expect($nodeIds)->toContain('question:1'); + expect($nodeIds)->toContain('condition:10'); + expect($nodeIds)->toContain('rule:20'); + expect($nodeIds)->toContain('task:30'); + expect($nodeIds)->toContain('vehicle_type:3'); + expect($nodeIds)->toContain('gateway:50'); + expect($nodeIds)->toContain('relay:relay-1'); + expect($edgeIds)->toContain('question-gate:10:1'); + expect($edgeIds)->toContain('task-gate:question:1:30'); + expect($edgeIds)->toContain('scope:vehicle_type:3:question:1'); + expect($edgeIds)->toContain('scope:vehicle_type:3:condition:10'); + expect($edgeIds)->toContain('scope:vehicle_type:3:task:30'); + expect($edgeIds)->toContain('gateway-binding:50:relay-1:0'); + expect($edgeIds)->toContain('relay-lane:relay-1:7:MACHINE'); +}); + +it('derives studio vehicle type lookup rows from selectable wash products', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts'); + + $vehicleTypes = $method->invoke($service, [ + ['id' => 3, 'name' => 'Forvogn', 'description' => 'Front vehicle', 'is_wash' => 1, 'subscription_allowed' => 1, 'order_priority' => 10], + ['id' => 4, 'name' => 'Trækker', 'description' => 'Tractor unit', 'is_wash' => '1', 'subscription_allowed' => '1', 'order_priority' => 20], + ['id' => 5, 'name' => 'Addon', 'description' => '', 'is_wash' => 0, 'subscription_allowed' => 1, 'order_priority' => 30], + ['id' => 6, 'name' => 'Internal wash', 'description' => '', 'is_wash' => 1, 'subscription_allowed' => 0, 'order_priority' => 40], + ]); + + expect(array_column($vehicleTypes, 'id'))->toBe([3, 4]); + expect(array_column($vehicleTypes, 'label'))->toBe(['Forvogn', 'Trækker']); + expect($vehicleTypes[0]['product'])->toBe(3); + expect($vehicleTypes[0]['product_id'])->toBe(3); + expect($vehicleTypes[0]['source'])->toBe('products'); +}); + +it('applies saved layout without changing graph semantics', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [ + ['id' => 1, 'question' => 'Question', 'order_priority' => 1], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], [ + 'lookups' => [ + 'labels' => [], + ], + ], [ + 'nodes' => [ + 'question:1' => ['x' => 123, 'y' => 456], + ], + ]); + + $questionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'question:1' + ))[0] ?? null; + + expect($questionNode)->not->toBeNull(); + expect($questionNode['position'])->toBe(['x' => 123.0, 'y' => 456.0]); +}); + +it('keeps layout loading compatible with native PDO named placeholders', function (): void { + $method = new ReflectionMethod(selfserve_studio_graph::class, 'loadLayout'); + $source = implode('', array_slice( + file((string)$method->getFileName()) ?: [], + $method->getStartLine() - 1, + $method->getEndLine() - $method->getStartLine() + 1 + )); + + expect($source)->not->toContain('user_id = :user_id OR user_id IS NULL'); + expect($source)->not->toContain('user_id = :user_id THEN 0 ELSE 1'); + expect($source)->toContain(':user_id_filter'); + expect($source)->toContain(':user_id_sort'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php new file mode 100644 index 00000000..56bd28b9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php @@ -0,0 +1,92 @@ + $client->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'real-relay', + 'on' => true, + ]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode'); + + $entries = shelly::blockedRequestLog(); + expect($entries)->toHaveCount(1) + ->and($entries[0]['method'])->toBe('POST') + ->and($entries[0]['endpoint'])->toBe('/v2/devices/api/set/switch') + ->and($entries[0]['data'])->toMatchArray(['id' => 'real-relay', 'on' => true]); + + $logLines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + expect($logLines)->not->toBeFalse(); + $logged = json_decode((string)$logLines[0], true); + expect($logged)->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/v2/devices/api/set/switch', + ]); + } finally { + shelly::resetBlockedRequestLog(); + @unlink($logPath); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog); + } +}); + +it('blocks and records test-mode Shelly GET requests before cURL can run', function (): void { + $previousBlock = getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY'); + $previousLog = getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG'); + $logPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-get-' . uniqid('', true) . '.jsonl'; + + try { + putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1'); + putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $logPath); + shelly::resetBlockedRequestLog(); + + $client = new ShellyRealRequestGuardHarness(); + + expect(fn() => $client->sendGetRequest('/device/all_status', [ + 'show_info' => 'true', + ]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode'); + + $entries = shelly::blockedRequestLog(); + expect($entries)->toHaveCount(1) + ->and($entries[0]['method'])->toBe('GET') + ->and($entries[0]['endpoint'])->toBe('/device/all_status') + ->and($entries[0]['data'])->toMatchArray(['show_info' => 'true']); + } finally { + shelly::resetBlockedRequestLog(); + @unlink($logPath); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog); + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php b/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php new file mode 100644 index 00000000..3027bc3a --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php @@ -0,0 +1,5 @@ +toBe([]); +});