> */ public function listActive(int $tenantId): array { return $this->repository->listActive($tenantId); } /** * @api Called from templates-data endpoint * @param array $filters * @return array{total: int, rows: list>} */ public function listPaged(int $tenantId, array $filters): array { return $this->repository->listPaged($tenantId, $filters); } public function findById(int $tenantId, int $id): ?array { return $this->repository->findById($tenantId, $id); } public function findByCode(int $tenantId, string $code): ?array { return $this->repository->findByCode($tenantId, $code); } /** * Decode a template's stored tech schema into {version, items}. * * @param array $template * @return array{version: int, items: list>} */ public function decodeSchema(array $template): array { $decoded = json_decode((string) ($template['tech_schema_json'] ?? ''), true); if (!is_array($decoded)) { return ['version' => (int) ($template['version'] ?? 1), 'items' => []]; } $items = is_array($decoded['items'] ?? null) ? array_values($decoded['items']) : []; return ['version' => (int) ($decoded['version'] ?? ($template['version'] ?? 1)), 'items' => $items]; } /** * @return array{ok: bool, id?: int, errors: array} */ public function create(int $tenantId, string $code, string $name, int $actorUserId): array { $code = trim($code); $name = trim($name); $errors = []; if ($tenantId <= 0) { $errors['tenant'] = t('No tenant context'); } if ($code === '') { $errors['product_code'] = t('Product code cannot be empty'); } elseif (!preg_match('/^[A-Za-z0-9._-]+$/', $code)) { $errors['product_code'] = t('Product code is invalid'); } elseif ($this->repository->codeExists($tenantId, $code)) { $errors['product_code'] = t('A template with this product code already exists'); } if ($name === '') { $errors['product_name'] = t('Product name cannot be empty'); } if ($errors !== []) { return ['ok' => false, 'errors' => $errors]; } $id = $this->repository->insert($tenantId, [ 'product_code' => $code, 'product_name' => $name, 'tech_schema_json' => json_encode(['version' => 1, 'items' => []]), 'active' => 1, 'version' => 1, 'created_by' => $actorUserId, ]); if ($id === null) { return ['ok' => false, 'errors' => ['general' => t('Template could not be created')]]; } return ['ok' => true, 'id' => $id, 'errors' => []]; } /** * @return array{ok: bool, errors: array} */ public function updateMeta(int $tenantId, int $id, string $name, bool $active, int $actorUserId): array { $name = trim($name); if ($name === '') { return ['ok' => false, 'errors' => ['product_name' => t('Product name cannot be empty')]]; } if ($this->repository->findById($tenantId, $id) === null) { return ['ok' => false, 'errors' => ['general' => t('Template not found')]]; } $ok = $this->repository->updateMeta($tenantId, $id, $name, $active, $actorUserId); return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Template could not be updated')]]; } /** * Validate + normalize the technical checklist and persist it (version bump). * * @param array $items * @return array{ok: bool, errors: array, version?: int} */ public function saveTechSchema(int $tenantId, int $id, array $items, int $actorUserId): array { $template = $this->repository->findById($tenantId, $id); if ($template === null) { return ['ok' => false, 'errors' => ['general' => t('Template not found')]]; } if (count($items) > self::MAX_ITEMS) { return ['ok' => false, 'errors' => ['schema' => t('Maximum %d items allowed', self::MAX_ITEMS)]]; } $normalized = []; $usedKeys = []; foreach ($items as $index => $item) { if (!is_array($item)) { continue; } $type = (string) ($item['type'] ?? 'check'); if (!in_array($type, self::ALLOWED_ITEM_TYPES, true)) { return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid type', $index + 1)]]; } $label = trim((string) ($item['label'] ?? '')); if ($label === '') { return ['ok' => false, 'errors' => ['schema' => t('Item %d is missing a label', $index + 1)]]; } if ($type === 'section') { $normalized[] = ['type' => 'section', 'label' => $label]; continue; } $key = trim((string) ($item['key'] ?? '')); if ($key === '') { $key = self::slugify($label); } $key = self::slugify($key); if ($key === '') { return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid key', $index + 1)]]; } if (isset($usedKeys[$key])) { $suffix = 2; while (isset($usedKeys[$key . '_' . $suffix])) { $suffix++; } $key .= '_' . $suffix; } $usedKeys[$key] = true; $entry = ['type' => 'check', 'key' => $key, 'label' => $label]; $hint = trim((string) ($item['hint'] ?? '')); if ($hint !== '') { $entry['hint'] = $hint; } $markdown = trim((string) ($item['markdown'] ?? '')); if ($markdown !== '') { $entry['markdown'] = $markdown; } $normalized[] = $entry; } $currentVersion = (int) ($template['version'] ?? 1); $newVersion = $currentVersion + 1; $schemaJson = json_encode(['version' => $newVersion, 'items' => $normalized], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($schemaJson)) { return ['ok' => false, 'errors' => ['schema' => t('Could not encode the checklist')]]; } $ok = $this->repository->updateSchema($tenantId, $id, $schemaJson, $newVersion, $actorUserId); return $ok ? ['ok' => true, 'errors' => [], 'version' => $newVersion] : ['ok' => false, 'errors' => ['general' => t('Checklist could not be saved')]]; } private static function slugify(string $text): string { $text = trim(mb_strtolower($text)); $text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); $text = preg_replace('/[^a-z0-9]+/', '_', $text) ?? ''; return trim($text, '_'); } }