*/ $GLOBALS['qdb_dotenv'] = []; function qdb_env_file_path(): string { return __DIR__ . '/.env'; } /** * Parse a dotenv file. Does not use getenv/putenv. * Not cached when unreadable (FPM workers can start before .env exists). * * @return array */ function qdb_parse_env_file(string $path): array { static $cache = []; $mtime = @filemtime($path) ?: 0; $cacheKey = $path . "\0" . $mtime; if ($mtime > 0 && isset($cache[$cacheKey])) { return $cache[$cacheKey]; } $out = []; if (!is_readable($path)) { return $out; } $lines = file($path, FILE_IGNORE_NEW_LINES); if ($lines === false) { return $out; } foreach ($lines as $line) { $line = trim($line); if ($line === '' || $line[0] === '#') { continue; } $eq = strpos($line, '='); if ($eq === false) { continue; } $name = trim(substr($line, 0, $eq)); $value = trim(substr($line, $eq + 1)); if ($name === '') { continue; } $len = strlen($value); if ($len >= 2) { $q = $value[0]; if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) { $value = substr($value, 1, -1); } } $out[$name] = $value; } if ($mtime > 0) { $cache[$cacheKey] = $out; } return $out; } /** * Read an environment variable (.env file first, then getenv / $_ENV / $_SERVER). */ function qdb_env_get(string $name): ?string { $fileVars = qdb_parse_env_file(qdb_env_file_path()); if (isset($fileVars[$name]) && $fileVars[$name] !== '') { return $fileVars[$name]; } if (isset($GLOBALS['qdb_dotenv'][$name]) && $GLOBALS['qdb_dotenv'][$name] !== '') { return $GLOBALS['qdb_dotenv'][$name]; } // Do not use getenv() for QDB_MASTER_KEY — FPM can retain a stale system value // when .env was missing on the worker's first request. if ($name === 'QDB_MASTER_KEY') { return null; } $v = getenv($name); if ($v !== false && $v !== '') { return $v; } if (isset($_ENV[$name]) && $_ENV[$name] !== '') { return (string)$_ENV[$name]; } if (isset($_SERVER[$name]) && $_SERVER[$name] !== '') { return (string)$_SERVER[$name]; } return null; } /** Set an environment variable for the current request (best-effort putenv). */ function qdb_env_set(string $name, string $value): void { $_ENV[$name] = $value; $_SERVER[$name] = $value; if (function_exists('putenv')) { @putenv("$name=$value"); } } /** Load KEY=value pairs from .env into $GLOBALS['qdb_dotenv'] and $_ENV. */ function qdb_load_dotenv(string $path): void { foreach (qdb_parse_env_file($path) as $name => $value) { $GLOBALS['qdb_dotenv'][$name] = $value; qdb_env_set($name, $value); } } qdb_load_dotenv(qdb_env_file_path()); // --- MASTER-KEY (Server-seitig zum Speichern der DB) --- // Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String). function get_master_key_bytes(): string { $env = qdb_env_get('QDB_MASTER_KEY'); if ($env === null) { $hint = is_readable(qdb_env_file_path()) ? 'missing QDB_MASTER_KEY in .env' : '.env not readable by PHP (' . qdb_env_file_path() . ')'; throw new RuntimeException('QDB_MASTER_KEY is not set. ' . $hint); } $b = base64_decode($env, true); if ($b !== false && strlen($b) > 0) { return str_pad(substr($b, 0, 32), 32, "\0"); } return str_pad(substr($env, 0, 32), 32, "\0"); } // --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) --- // Kompatibel zu Android-Implementierung: salt = leer, info = "qdb-aes" function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes', int $len = 32): string { $ikm = @hex2bin(trim($tokenHex)); if ($ikm === false) $ikm = $tokenHex; // Falls bereits binär if (function_exists('hash_hkdf')) { return hash_hkdf('sha256', $ikm, $len, $info, ''); } // Fallback: eigene HKDF-Implementierung $hashLen = 32; $salt = str_repeat("\0", $hashLen); $prk = hash_hmac('sha256', $ikm, $salt, true); $okm = ''; $t = ''; $n = (int)ceil($len / $hashLen); for ($i = 1; $i <= $n; $i++) { $t = hash_hmac('sha256', $t . $info . chr($i), $prk, true); $okm .= $t; } return substr($okm, 0, $len); } // --- Token-Storage (DB-backed via session table) --- function token_storage_key(string $token): string { return hash('sha256', $token); } function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void { require_once __DIR__ . '/db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(true); $now = time(); $pdo->prepare( "INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp) VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)" )->execute([ ':t' => token_storage_key($token), ':uid' => $extra['userID'] ?? '', ':role' => $extra['role'] ?? '', ':eid' => $extra['entityID'] ?? '', ':ca' => $now, ':ea' => $now + $ttlSeconds, ':tmp' => (int)(!empty($extra['temp'])), ]); // Opportunistic cleanup: remove expired sessions $pdo->prepare("DELETE FROM session WHERE expiresAt < :now")->execute([':now' => $now]); qdb_save($tmpDb, $lockFp); } function token_get_record(string $token, bool $refreshExpiry = true): ?array { require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/lib/settings.php'; [$pdo, $tmpDb, $lockFp] = qdb_open($refreshExpiry); $now = time(); $stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now"); $storedToken = token_storage_key($token); $stmt->execute([':t' => $storedToken, ':now' => $now]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { qdb_discard($tmpDb, $lockFp); return null; } if ($refreshExpiry) { $settings = qdb_settings_get_on_pdo($pdo); $newExpiry = $now + qdb_session_ttl_seconds($settings, !empty($row['temp'])); $pdo->prepare('UPDATE session SET expiresAt = :exp WHERE token = :t') ->execute([':exp' => $newExpiry, ':t' => $storedToken]); $row['expiresAt'] = $newExpiry; qdb_save($tmpDb, $lockFp); } else { qdb_discard($tmpDb, $lockFp); } // Map DB columns to the keys the rest of the codebase expects return [ 'token' => $row['token'], 'role' => $row['role'], 'entityID' => $row['entityID'], 'userID' => $row['userID'], 'created' => (int)$row['createdAt'], 'exp' => (int)$row['expiresAt'], 'temp' => (int)$row['temp'], ]; } function token_revoke(string $token): void { require_once __DIR__ . '/db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(true); $pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => token_storage_key($token)]); qdb_save($tmpDb, $lockFp); } /** Revoke all sessions for one user on an open writable connection. */ function token_revoke_all_for_user_on_pdo(PDO $pdo, string $userID): int { if ($userID === '') { return 0; } $stmt = $pdo->prepare('DELETE FROM session WHERE userID = :uid'); $stmt->execute([':uid' => $userID]); return $stmt->rowCount(); } /** Revoke every login session for a user (after password change or admin reset). */ function token_revoke_all_for_user(string $userID): int { if ($userID === '') { return 0; } require_once __DIR__ . '/db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(true); $removed = token_revoke_all_for_user_on_pdo($pdo, $userID); qdb_save($tmpDb, $lockFp); return $removed; } /** Revoke every stored session (all users and devices). */ function token_revoke_all_on_pdo(PDO $pdo): int { if (!qdb_table_exists($pdo, 'session')) { return 0; } $count = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn(); $pdo->exec('DELETE FROM session'); return $count; } function token_revoke_all(): int { require_once __DIR__ . '/db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(true); $removed = token_revoke_all_on_pdo($pdo); qdb_save($tmpDb, $lockFp); return $removed; } // --- Auth helpers for endpoints --- function get_bearer_token(): ?string { $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? ''); if (stripos($auth, 'Bearer ') === 0) { return substr($auth, 7); } return null; } function require_valid_token(): array { $token = get_bearer_token(); if (!$token) { json_error('UNAUTHORIZED', 'Missing Bearer token', 401); } $rec = token_get_record($token); if (!$rec) { json_error('UNAUTHORIZED', 'Invalid or expired token', 401); } if (!empty($rec['temp'])) { json_error('PASSWORD_CHANGE_REQUIRED', 'Password change required before access', 403); } $rec['_token'] = $token; return $rec; } function require_role(array $allowed, array $tokenRecord): void { $role = $tokenRecord['role'] ?? ''; if (!in_array($role, $allowed, true)) { json_error('FORBIDDEN', 'Insufficient permissions', 403); } } /** Roles that may use the management website (not the mobile app). */ function qdb_web_roles(): array { return ['admin', 'supervisor']; } /** True when the request originates from the management website (see website/js/api.js). */ function qdb_is_web_client_request(): bool { $h = $_SERVER['HTTP_X_QDB_CLIENT'] ?? ''; return strtolower(trim($h)) === 'web'; } /** Website or coordinator mobile app — not the counselor field app. */ function qdb_is_management_client_request(): bool { $h = strtolower(trim($_SERVER['HTTP_X_QDB_CLIENT'] ?? '')); return in_array($h, ['web', 'coordinator'], true); } /** Reject coach sign-in from management clients; coaches use the field app only. */ function qdb_reject_coach_web_login(string $role): void { if ($role === 'coach' && qdb_is_management_client_request()) { json_error( 'FORBIDDEN', 'Counselor accounts can only sign in through the mobile app.', 403 ); } } /** Valid bearer token plus admin/supervisor role (website API). */ function require_valid_token_web(): array { $rec = require_valid_token(); require_role(qdb_web_roles(), $rec); return $rec; } /** * Build a SQL WHERE clause fragment that restricts client visibility by role. * Returns [string $clause, array $params]. * $clientAlias is the table alias or name that has a coachID column (e.g. 'client'). */ /** * @param string $archiveMode active — non-archived only (default); archived — archived only; all — no archive filter */ function rbac_client_filter( array $tokenRecord, string $clientAlias = 'client', string $archiveMode = 'active' ): array { $role = $tokenRecord['role'] ?? ''; $entityID = $tokenRecord['entityID'] ?? ''; switch ($role) { case 'admin': $clause = '1=1'; $params = []; break; case 'supervisor': $clause = "$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)"; $params = [':rbac_eid' => $entityID]; break; case 'coach': $clause = "$clientAlias.coachID = :rbac_eid"; $params = [':rbac_eid' => $entityID]; break; default: return ['0=1', []]; } $archiveClause = match ($archiveMode) { 'archived' => "COALESCE($clientAlias.archived, 0) = 1", 'all' => '1=1', default => "COALESCE($clientAlias.archived, 0) = 0", }; return ["($clause) AND ($archiveClause)", $params]; } // --- Translations: German (de) is the canonical source language --- define('QDB_SOURCE_LANGUAGE', 'de'); function qdb_ensure_source_language(PDO $pdo): void { $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) ON CONFLICT(languageCode) DO NOTHING") ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); } function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void { qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text); } /** Catalog of global app UI string keys (LanguageManager). */ function qdb_app_string_catalog(bool $reload = false): array { static $catalog = null; if (!$reload && $catalog !== null) { return $catalog; } $path = qdb_app_string_catalog_path(); if (!is_readable($path)) { $catalog = ['keys' => [], 'germanDefaults' => []]; return $catalog; } $data = json_decode(file_get_contents($path), true); $catalog = is_array($data) ? $data : ['keys' => [], 'germanDefaults' => []]; return $catalog; } function qdb_app_string_catalog_path(): string { return __DIR__ . '/data/app_ui_strings.json'; } /** Remove questionnaire_group_* from catalog file (keys list + germanDefaults). */ function qdb_remove_questionnaire_group_from_catalog(string $stringKey): void { if (!qdb_is_questionnaire_group_string_key($stringKey)) { return; } $path = qdb_app_string_catalog_path(); if (!is_readable($path) || !is_writable($path)) { return; } $data = json_decode(file_get_contents($path), true); if (!is_array($data)) { return; } $changed = false; if (!empty($data['keys']) && is_array($data['keys'])) { $next = array_values(array_filter( $data['keys'], fn($k) => (string)$k !== $stringKey )); if (count($next) !== count($data['keys'])) { $data['keys'] = $next; $changed = true; } } if (!empty($data['germanDefaults']) && is_array($data['germanDefaults']) && array_key_exists($stringKey, $data['germanDefaults'])) { unset($data['germanDefaults'][$stringKey]); $changed = true; } if (!$changed) { return; } file_put_contents( $path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX ); qdb_app_string_catalog(true); } function qdb_app_string_key_set(): array { $keys = qdb_app_string_catalog()['keys'] ?? []; return array_flip($keys); } /** Keys for dev-only UI (database export, etc.) — excluded from website App UI and app sync catalog. */ function qdb_dev_only_app_string_key_set(): array { static $set = null; if ($set !== null) { return $set; } $set = array_flip([ 'database', 'database_clients_title', 'download_header', 'export_success_downloads', 'export_failed', 'headers', 'view_missing', 'no_header_template_found', 'saved_pdf_csv', 'no_pdf_viewer', 'save_error', 'no_clients_available', 'questionnaire_id', 'questionnaires', 'status', 'data', 'data_final_warning', 'open_client_via_load', 'ask_before_upload', 'You have completed questionnaires that have not been uploaded yet.', 'You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.', ]); return $set; } /** Prefix for app UI strings that label questionnaire category groups on the opening screen. */ function qdb_questionnaire_group_prefix(): string { return 'questionnaire_group_'; } function qdb_questionnaire_group_string_key(string $categoryKey): string { $categoryKey = trim($categoryKey); return $categoryKey === '' ? '' : qdb_questionnaire_group_prefix() . $categoryKey; } function qdb_is_questionnaire_group_string_key(string $stringKey): bool { return str_starts_with($stringKey, qdb_questionnaire_group_prefix()); } function qdb_category_key_from_group_string_key(string $stringKey): string { $prefix = qdb_questionnaire_group_prefix(); return str_starts_with($stringKey, $prefix) ? substr($stringKey, strlen($prefix)) : ''; } /** @return array categoryKey => questionnaire count */ function qdb_category_keys_in_use(PDO $pdo): array { $map = []; $rows = $pdo->query( "SELECT categoryKey, COUNT(*) AS c FROM questionnaire WHERE trim(categoryKey) != '' GROUP BY categoryKey" )->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $row) { $k = trim((string)($row['categoryKey'] ?? '')); if ($k !== '') { $map[$k] = (int)$row['c']; } } return $map; } /** * App string entries for category keys in use (not already in the static JSON catalog keys list). * @return list */ function qdb_category_group_string_entries(PDO $pdo): array { $catalog = qdb_app_string_catalog(); $defaults = $catalog['germanDefaults'] ?? []; $staticKeys = qdb_app_string_key_set(); $entries = []; $order = 0; foreach (qdb_category_keys_in_use($pdo) as $catKey => $_count) { $stringKey = qdb_questionnaire_group_string_key($catKey); if ($stringKey === '' || isset($staticKeys[$stringKey])) { continue; } $entries[] = [ 'key' => $stringKey, 'displayKey' => $stringKey, 'type' => 'app_string', 'entityId' => $stringKey, 'sortOrder' => $order++, 'germanDefault' => $defaults[$stringKey] ?? $catKey, 'categoryKey' => $catKey, ]; } usort($entries, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey'])); return $entries; } /** * Category keys with translations in DB but no questionnaire using them. * @return list */ function qdb_orphaned_category_keys(PDO $pdo): array { $inUse = qdb_category_keys_in_use($pdo); $prefix = qdb_questionnaire_group_prefix(); $orphans = []; $stmt = $pdo->query( "SELECT DISTINCT stringKey FROM string_translation WHERE stringKey LIKE " . $pdo->quote($prefix . '%') ); foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $stringKey) { $catKey = qdb_category_key_from_group_string_key((string)$stringKey); if ($catKey !== '' && !isset($inUse[$catKey])) { $orphans[$catKey] = true; } } ksort($orphans); return array_keys($orphans); } /** * @return list */ function qdb_list_category_keys_for_dashboard(PDO $pdo): array { $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; $inUse = qdb_category_keys_in_use($pdo); $list = []; $loadGerman = function (string $stringKey) use ($pdo, $defaults): string { $stmt = $pdo->prepare( 'SELECT text FROM string_translation WHERE stringKey = :k AND languageCode = :lang LIMIT 1' ); $stmt->execute([':k' => $stringKey, ':lang' => QDB_SOURCE_LANGUAGE]); $text = $stmt->fetchColumn(); if (is_string($text) && $text !== '') { return $text; } return $defaults[$stringKey] ?? ''; }; foreach ($inUse as $catKey => $count) { $stringKey = qdb_questionnaire_group_string_key($catKey); $list[] = [ 'categoryKey' => $catKey, 'stringKey' => $stringKey, 'questionnaireCount' => $count, 'orphaned' => false, 'germanLabel' => $loadGerman($stringKey) ?: $catKey, ]; } foreach (qdb_orphaned_category_keys($pdo) as $catKey) { $stringKey = qdb_questionnaire_group_string_key($catKey); $list[] = [ 'categoryKey' => $catKey, 'stringKey' => $stringKey, 'questionnaireCount' => 0, 'orphaned' => true, 'germanLabel' => $loadGerman($stringKey) ?: $catKey, ]; } usort($list, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey'])); return $list; } /** Persist default German for a questionnaire_group_* key in app_ui_strings.json. */ function qdb_update_questionnaire_group_german_default(string $stringKey, string $text): void { if (!qdb_is_questionnaire_group_string_key($stringKey)) { return; } $path = qdb_app_string_catalog_path(); if (!is_readable($path) || !is_writable($path)) { return; } $data = json_decode(file_get_contents($path), true); if (!is_array($data)) { return; } if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) { $data['germanDefaults'] = []; } if (($data['germanDefaults'][$stringKey] ?? '') === $text) { return; } $data['germanDefaults'][$stringKey] = $text; file_put_contents( $path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX ); qdb_app_string_catalog(true); } /** Set German label for a category (app opening screen group title). */ function qdb_set_category_german_label(PDO $pdo, string $categoryKey, string $text): string { $categoryKey = trim($categoryKey); if ($categoryKey === '') { json_error('INVALID_FIELD', 'categoryKey is required', 400); } $text = trim($text); if ($text === '') { json_error('INVALID_FIELD', 'German label is required', 400); } $stringKey = qdb_questionnaire_group_string_key($categoryKey); qdb_set_app_string_german_label($pdo, $stringKey, $text); qdb_update_questionnaire_group_german_default($stringKey, $text); return $stringKey; } /** * Remove a category key completely: clear questionnaires, translations, and catalog defaults. * @return int questionnaires updated */ function qdb_delete_category_key(PDO $pdo, string $categoryKey): int { $categoryKey = trim($categoryKey); if ($categoryKey === '') { json_error('INVALID_FIELD', 'categoryKey is required', 400); } $stringKey = qdb_questionnaire_group_string_key($categoryKey); $stmt = $pdo->prepare('UPDATE questionnaire SET categoryKey = \'\' WHERE categoryKey = :ck'); $stmt->execute([':ck' => $categoryKey]); $cleared = $stmt->rowCount(); $pdo->prepare('DELETE FROM string_translation WHERE stringKey = :k') ->execute([':k' => $stringKey]); qdb_remove_questionnaire_group_from_catalog($stringKey); return $cleared; } /** * Global app UI strings (screens, toasts, auth, etc.) for the mobile app. * @return list */ function qdb_app_ui_string_entries(): array { $catalog = qdb_app_string_catalog(); $defaults = $catalog['germanDefaults'] ?? []; $entries = []; $order = 0; foreach ($catalog['keys'] ?? [] as $key) { if (qdb_is_questionnaire_group_string_key($key)) { continue; } $entries[] = [ 'key' => $key, 'displayKey' => $key, 'type' => 'app_string', 'entityId' => $key, 'sortOrder' => $order++, 'germanDefault' => $defaults[$key] ?? $key, ]; } return $entries; } /** * Static catalog plus questionnaire_group_* keys for categoryKey values in use. * @return list */ /** messageKey field inside questionnaire conditionJson (app locked hint). */ function qdb_message_key_from_condition_json(?string $json): string { if ($json === null || trim($json) === '' || trim($json) === '{}') { return ''; } $obj = json_decode($json, true); if (!is_array($obj)) { return ''; } $key = trim((string)($obj['messageKey'] ?? '')); return $key !== '' && qdb_is_stable_key($key) ? $key : ''; } /** @return array */ function qdb_collect_condition_message_keys(PDO $pdo): array { $keys = []; foreach ($pdo->query('SELECT conditionJson FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN) as $json) { $mk = qdb_message_key_from_condition_json($json); if ($mk !== '') { $keys[$mk] = true; } } return $keys; } /** * App string entries for condition messageKey values referenced in questionnaire conditions. * @return list */ function qdb_condition_message_string_entries(PDO $pdo): array { $staticKeys = qdb_app_string_key_set(); $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; $entries = []; $order = 0; foreach (array_keys(qdb_collect_condition_message_keys($pdo)) as $key) { if (isset($staticKeys[$key])) { continue; } $entries[] = [ 'key' => $key, 'displayKey' => $key, 'type' => 'app_string', 'entityId' => $key, 'sortOrder' => $order++, 'germanDefault' => $defaults[$key] ?? $key, ]; } usort($entries, fn($a, $b) => strcmp($a['key'], $b['key'])); return $entries; } function qdb_update_app_string_german_default(string $stringKey, string $text): void { $path = qdb_app_string_catalog_path(); if (!is_readable($path) || !is_writable($path)) { return; } $data = json_decode(file_get_contents($path), true); if (!is_array($data)) { return; } if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) { $data['germanDefaults'] = []; } if (($data['germanDefaults'][$stringKey] ?? '') === $text) { return; } $data['germanDefaults'][$stringKey] = $text; file_put_contents( $path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX ); qdb_app_string_catalog(true); } function qdb_set_app_string_german_label(PDO $pdo, string $stringKey, string $text): void { $stringKey = qdb_validate_stable_key($stringKey, 'messageKey'); $text = trim($text); if ($text === '') { json_error('INVALID_FIELD', 'German label is required', 400); } qdb_put_translation($pdo, 'app_string', $stringKey, QDB_SOURCE_LANGUAGE, $text); qdb_update_app_string_german_default($stringKey, $text); } function qdb_app_ui_string_entries_merged(PDO $pdo): array { $merged = qdb_app_ui_string_entries(); $existing = []; foreach ($merged as $e) { $existing[$e['key']] = true; } $order = count($merged); foreach (qdb_category_group_string_entries($pdo) as $e) { if (!isset($existing[$e['key']])) { $e['sortOrder'] = $order++; $merged[] = $e; $existing[$e['key']] = true; } } foreach (qdb_condition_message_string_entries($pdo) as $e) { if (!isset($existing[$e['key']])) { $e['sortOrder'] = $order++; $merged[] = $e; $existing[$e['key']] = true; } } return $merged; } /** * All string keys referenced by questionnaire content (questions, options, config). * @return array */ function qdb_all_questionnaire_translation_key_set(PDO $pdo): array { static $cache = null; if ($cache !== null) { return $cache; } $keys = []; $rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $dbQ) { $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; $qk = qdb_question_key($config, $dbQ['defaultText']); if ($qk !== '') { $keys[$qk] = true; if (!empty($config['noteBefore'])) { $keys[qdb_note_before_key($qk)] = true; } if (!empty($config['noteAfter'])) { $keys[qdb_note_after_key($qk)] = true; } } elseif ($dbQ['defaultText'] !== '') { $keys[$dbQ['defaultText']] = true; } foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { if (!empty($config[$field])) { $keys[$config[$field]] = true; } } if (!empty($config['symptoms']) && is_array($config['symptoms'])) { foreach ($config['symptoms'] as $s) { if ($s !== '') { $keys[$s] = true; } } } if (!empty($config['options']) && is_array($config['options'])) { foreach ($config['options'] as $opt) { if (is_string($opt) && $opt !== '') { $keys[$opt] = true; } elseif (is_array($opt) && !empty($opt['key'])) { $keys[$opt['key']] = true; } } } } foreach ($pdo->query("SELECT defaultText FROM answer_option")->fetchAll(PDO::FETCH_COLUMN) as $text) { if ($text !== '') { $keys[$text] = true; } } $cache = $keys; return $cache; } /** * App UI catalog for the website Translations page (coach-visible, no dev-only keys). * Catalog keys always appear here even when also used inside a questionnaire (e.g. "save"). */ function qdb_app_ui_string_entries_for_website(PDO $pdo): array { $dev = qdb_dev_only_app_string_key_set(); $entries = []; foreach (qdb_app_ui_string_entries_merged($pdo) as $e) { if (!isset($dev[$e['key']])) { $entries[] = $e; } } return $entries; } /** Upsert one translation row; sync German text into defaultText for questions/options. */ function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void { if ($type === 'question') { $pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text) VALUES (:id, :lang, :t) ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text") ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); if ($lang === QDB_SOURCE_LANGUAGE) { $pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id") ->execute([':t' => $text, ':id' => $id]); } } elseif ($type === 'answer_option') { $pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text) VALUES (:id, :lang, :t) ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); // defaultText holds optionKey; German label lives only in answer_option_translation. } elseif ($type === 'string' || $type === 'app_string') { $pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text) VALUES (:id, :lang, :t) ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text") ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); } } /** Short label for translation UI (Key column); full text stays in `key` for API lookup. */ function qdb_translation_display_key(array $e): string { return qdb_translation_row_label($e); } function qdb_short_entity_label(string $entityId): string { $pos = strrpos($entityId, '__'); return $pos !== false ? substr($entityId, $pos + 2) : $entityId; } function qdb_require_non_empty_german(string $text, string $label = 'German text'): void { if ($text === '') { json_error('MISSING_FIELDS', "$label is required", 400); } } /** Stable identifier: snake_case starting with a letter (case preserved for LM keys). */ function qdb_is_stable_key(string $s): bool { return $s !== '' && (bool)preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $s); } /** * Coerce an arbitrary key string into a stable identifier (letters, digits, underscores; * must start with a letter). Used on import and API writes so bundles are not limited to * pre-validated keys (e.g. numeric option labels "1", "42"). */ function qdb_normalize_stable_key(string $s): string { $s = trim($s); if ($s === '') { return ''; } if (qdb_is_stable_key($s)) { return $s; } $out = preg_replace('/[^a-zA-Z0-9_]+/', '_', $s); $out = preg_replace('/_+/', '_', $out ?? ''); $out = trim($out, '_'); if ($out === '') { return 'k_' . substr(hash('crc32b', $s), 0, 8); } if (!preg_match('/^[a-zA-Z]/', $out)) { $out = 'k_' . $out; } return qdb_is_stable_key($out) ? $out : 'k_' . substr(hash('crc32b', $s), 0, 8); } function qdb_validate_stable_key(string $s, string $label = 'Key'): string { $s = trim($s); if ($s === '') { json_error('INVALID_FIELD', "$label is required", 400); } $normalized = qdb_normalize_stable_key($s); if (!qdb_is_stable_key($normalized)) { json_error('INVALID_FIELD', "$label could not be normalized to a valid key", 400); } return $normalized; } /** Resolve app lookup key for a question row. */ function qdb_question_key(array $config, string $defaultText): string { $key = trim((string)($config['questionKey'] ?? '')); if ($key !== '' && qdb_is_stable_key($key)) { return $key; } if (qdb_is_stable_key($defaultText)) { return $defaultText; } return ''; } /** Option key is stored in answer_option.defaultText. */ function qdb_option_key(array $aoRow): string { $dt = trim((string)($aoRow['defaultText'] ?? '')); return qdb_is_stable_key($dt) ? $dt : ''; } /** Human-readable text for glass-scale answers stored as JSON on the parent question row. */ function qdb_format_glass_answer_json(?string $raw): string { if ($raw === null || trim($raw) === '') { return ''; } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return $raw; } $parts = []; foreach ($decoded as $key => $val) { $val = trim((string)$val); if ($val === '') { continue; } $parts[] = $key . ': ' . $val; } return implode('; ', $parts); } /** * Map glass symptom string keys to parent questionID for one questionnaire. * * @return array symptomKey => parentQuestionID */ function qdb_glass_symptom_parent_map(PDO $pdo, string $qnID): array { $stmt = $pdo->prepare( "SELECT questionID, configJson FROM question WHERE questionnaireID = :qn AND type = 'glass_scale_question'" ); $stmt->execute([':qn' => $qnID]); $map = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $cfg = json_decode($row['configJson'] ?? '{}', true) ?: []; foreach ($cfg['symptoms'] ?? [] as $symptomKey) { $sk = trim((string)$symptomKey); if ($sk !== '') { $map[$sk] = $row['questionID']; } } } return $map; } /** Merge symptom => label selections into JSON for one glass-scale parent question. */ function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabels): string { $data = []; if ($existingJson !== null && trim($existingJson) !== '') { $decoded = json_decode($existingJson, true); if (is_array($decoded)) { $data = $decoded; } } foreach ($symptomLabels as $key => $label) { $k = trim((string)$key); $v = trim((string)$label); if ($k !== '' && $v !== '') { $data[$k] = $v; } } return json_encode($data, JSON_UNESCAPED_UNICODE); } /** Single symptom value from glass-scale JSON stored on the parent question row. */ function qdb_glass_symptom_answer_value(?string $json, string $symptomKey): string { $symptomKey = trim($symptomKey); if ($symptomKey === '' || $json === null || trim($json) === '') { return ''; } $decoded = json_decode($json, true); if (!is_array($decoded)) { return ''; } return trim((string)($decoded[$symptomKey] ?? '')); } /** * Question types that exist only for app/editor flow (no answer payload to persist). */ function qdb_is_non_stored_answer_question_type(string $type): bool { return $type === 'last_page'; } /** * Flat columns for results / CSV: one column per glass-scale symptom. * * @return list */ function qdb_results_export_columns(array $questions, string $qnID): array { $columns = []; foreach ($questions as $q) { $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; $type = $q['type'] ?? ''; if (qdb_is_non_stored_answer_question_type($type)) { continue; } if ($type === 'glass_scale_question') { $symptoms = $cfg['symptoms'] ?? []; if (is_array($symptoms) && $symptoms !== []) { foreach ($symptoms as $symptomKey) { $sk = trim((string)$symptomKey); if ($sk === '') { continue; } $columns[] = [ 'header' => $sk, 'questionID' => $q['questionID'], 'symptomKey' => $sk, 'kind' => 'glass_symptom', 'question' => $q, ]; } continue; } } $columns[] = [ 'header' => qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID), 'questionID' => $q['questionID'], 'symptomKey' => null, 'kind' => 'question', 'question' => $q, ]; } return $columns; } /** Cell value for one export/results column (including glass symptom columns). */ function qdb_results_column_cell_value(array $column, ?array $answerRow, array $optionTextMap): string { if (($column['kind'] ?? '') === 'glass_symptom') { return qdb_glass_symptom_answer_value( $answerRow['freeTextValue'] ?? null, (string)($column['symptomKey'] ?? '') ); } return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap); } /** Format one client_answer row for results table / CSV export. */ function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string { if (!$answerRow) { return ''; } if (($question['type'] ?? '') === 'glass_scale_question') { return qdb_format_glass_answer_json($answerRow['freeTextValue'] ?? null); } $aoid = $answerRow['answerOptionID'] ?? null; if ($aoid && isset($optionTextMap[$aoid])) { return (string)$optionTextMap[$aoid]; } if (!empty($answerRow['freeTextValue'])) { return (string)$answerRow['freeTextValue']; } if (isset($answerRow['numericValue']) && $answerRow['numericValue'] !== null && $answerRow['numericValue'] !== '') { return (string)$answerRow['numericValue']; } return ''; } function qdb_note_before_key(string $questionKey): string { return $questionKey . '_note_before'; } function qdb_note_after_key(string $questionKey): string { return $questionKey . '_note_after'; } /** Strip deprecated fields and persist questionKey + notes in config. */ function qdb_normalize_question_config(array $config, string $questionKey, ?string $noteBefore = null, ?string $noteAfter = null): array { unset($config['multiline']); $config['questionKey'] = $questionKey; if ($noteBefore !== null) { $nb = trim($noteBefore); if ($nb !== '') { $config['noteBefore'] = $nb; } else { unset($config['noteBefore']); } } if ($noteAfter !== null) { $na = trim($noteAfter); if ($na !== '') { $config['noteAfter'] = $na; } else { unset($config['noteAfter']); } } return $config; } function qdb_parse_config_json($configJson): array { if (is_array($configJson)) { $config = $configJson; } else { $config = json_decode($configJson ?: '{}', true) ?: []; } unset($config['multiline']); return $config; } /** * @return list */ function qdb_parse_glass_symptoms_body(array $body, array $config): array { if (isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) { $rows = []; foreach ($body['glassSymptoms'] as $row) { if (!is_array($row)) { continue; } $key = trim((string)($row['key'] ?? '')); if ($key === '') { continue; } $key = qdb_validate_stable_key($key, 'Symptom key'); $rows[] = [ 'key' => $key, 'labelDe' => trim((string)($row['labelDe'] ?? '')), ]; } return $rows; } $out = []; foreach ($config['symptoms'] ?? [] as $symptomKey) { $key = trim((string)$symptomKey); if ($key !== '') { $out[] = ['key' => $key, 'labelDe' => '']; } } return $out; } /** Apply ordered symptom keys from editor rows into question config. */ function qdb_apply_glass_symptoms_config(array $config, array $glassSymptoms): array { $keys = []; foreach ($glassSymptoms as $row) { $keys[] = $row['key']; } if ($keys) { $config['symptoms'] = $keys; } else { unset($config['symptoms']); } return $config; } /** Upsert German labels for glass-scale symptom string keys. */ function qdb_sync_glass_symptom_strings(PDO $pdo, array $glassSymptoms): void { foreach ($glassSymptoms as $row) { $key = $row['key'] ?? ''; $label = trim((string)($row['labelDe'] ?? '')); if ($key === '' || $label === '') { continue; } qdb_put_translation($pdo, 'string', $key, QDB_SOURCE_LANGUAGE, $label); } } /** * Symptom keys with German labels for editor / API consumers. * * @return list */ function qdb_glass_symptoms_with_labels(PDO $pdo, array $config): array { $rows = []; foreach ($config['symptoms'] ?? [] as $symptomKey) { $key = trim((string)$symptomKey); if ($key === '') { continue; } $label = qdb_string_german_label($pdo, $key); if ($label === $key) { $label = ''; } $rows[] = ['key' => $key, 'labelDe' => $label]; } return $rows; } /** Sync note German text into string_translation rows for the app. */ function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void { if ($questionKey === '') { return; } foreach ([ 'noteBefore' => qdb_note_before_key($questionKey), 'noteAfter' => qdb_note_after_key($questionKey), ] as $field => $stringKey) { $text = trim((string)($config[$field] ?? '')); if ($text !== '') { qdb_put_translation($pdo, 'string', $stringKey, QDB_SOURCE_LANGUAGE, $text); } } } function qdb_assert_unique_question_key(PDO $pdo, string $qnID, string $questionKey, ?string $excludeQuestionID = null): void { $stmt = $pdo->prepare('SELECT questionID, configJson, defaultText FROM question WHERE questionnaireID = :qn'); $stmt->execute([':qn' => $qnID]); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { if ($excludeQuestionID !== null && $row['questionID'] === $excludeQuestionID) { continue; } $cfg = json_decode($row['configJson'] ?: '{}', true) ?: []; $existing = qdb_question_key($cfg, $row['defaultText']); if ($existing !== '' && $existing === $questionKey) { json_error('CONFLICT', "Question key \"$questionKey\" already exists in this questionnaire", 409); } } } function qdb_assert_unique_option_key(PDO $pdo, string $questionID, string $optionKey, ?string $excludeOptionID = null): void { $stmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid'); $stmt->execute([':qid' => $questionID]); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { if ($excludeOptionID !== null && $row['answerOptionID'] === $excludeOptionID) { continue; } if ($row['defaultText'] === $optionKey) { json_error('CONFLICT', "Option key \"$optionKey\" already exists on this question", 409); } } } /** German label for an option (translation de, else legacy defaultText when not a stable key). */ function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $storedDefaultText): string { $tr = qdb_fetch_translation_map($pdo, 'answer_option', $answerOptionID); $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); if ($de !== '') { return $de; } if (!qdb_is_stable_key($storedDefaultText)) { return $storedDefaultText; } return ''; } /** German label for a string catalog key (symptoms, glass levels, etc.). */ function qdb_string_german_label(PDO $pdo, string $stringKey, array &$cache = []): string { $stringKey = trim($stringKey); if ($stringKey === '') { return ''; } if (isset($cache[$stringKey])) { return $cache[$stringKey]; } $tr = qdb_fetch_translation_map($pdo, 'string', $stringKey); $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); $label = $de !== '' ? $de : $stringKey; $cache[$stringKey] = $label; return $label; } /** German question text for UI display. */ function qdb_question_german_label(PDO $pdo, array $questionRow): string { $tr = qdb_fetch_translation_map($pdo, 'question', $questionRow['questionID']); $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); if ($de !== '') { return $de; } $defaultText = trim($questionRow['defaultText'] ?? ''); if ($defaultText !== '' && !qdb_is_stable_key($defaultText)) { return $defaultText; } return qdb_question_local_id($questionRow['questionID']); } /** * Result columns + option map using German labels (client detail, not CSV export). * * @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array} */ function qdb_display_questionnaire_context(PDO $pdo, string $questionnaireID): array { $qStmt = $pdo->prepare( 'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex' ); $qStmt->execute([':id' => $questionnaireID]); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $stringLabelCache = []; $resultColumns = []; foreach ($questions as $q) { $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; $type = $q['type'] ?? ''; if (qdb_is_non_stored_answer_question_type($type)) { continue; } if ($type === 'glass_scale_question') { $symptoms = $cfg['symptoms'] ?? []; if (is_array($symptoms) && $symptoms !== []) { foreach ($symptoms as $symptomKey) { $sk = trim((string)$symptomKey); if ($sk === '') { continue; } $resultColumns[] = [ 'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache), 'questionID' => $q['questionID'], 'symptomKey' => $sk, 'kind' => 'glass_symptom', 'question' => $q, ]; } continue; } } $resultColumns[] = [ 'header' => qdb_question_german_label($pdo, $q), 'questionID' => $q['questionID'], 'symptomKey' => null, 'kind' => 'question', 'question' => $q, ]; } $optionTextMap = []; foreach ($questions as $q) { $aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid'); $aoStmt->execute([':qid' => $q['questionID']]); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { $optionTextMap[$ao['answerOptionID']] = qdb_option_german_label( $pdo, $ao['answerOptionID'], $ao['defaultText'] ); } } return [ 'questions' => $questions, 'resultColumns' => $resultColumns, 'optionTextMap' => $optionTextMap, 'stringLabelCache' => $stringLabelCache, ]; } /** Format one answer cell for UI (German labels). */ function qdb_display_column_cell_value( PDO $pdo, array $column, ?array $answerRow, array $optionTextMap, array &$stringLabelCache ): string { if (($column['kind'] ?? '') === 'glass_symptom') { $raw = qdb_glass_symptom_answer_value( $answerRow['freeTextValue'] ?? null, (string)($column['symptomKey'] ?? '') ); if ($raw === '') { return ''; } return qdb_string_german_label($pdo, $raw, $stringLabelCache); } if (($column['question']['type'] ?? '') === 'glass_scale_question') { return qdb_format_glass_answer_json_display($pdo, $answerRow['freeTextValue'] ?? null, $stringLabelCache); } return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap); } /** Glass-scale JSON with German symptom keys and level labels. */ function qdb_format_glass_answer_json_display(PDO $pdo, ?string $raw, array &$stringLabelCache): string { if ($raw === null || trim($raw) === '') { return ''; } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return $raw; } $parts = []; foreach ($decoded as $key => $val) { $val = trim((string)$val); if ($val === '') { continue; } $symLabel = qdb_string_german_label($pdo, (string)$key, $stringLabelCache); $levelLabel = qdb_string_german_label($pdo, $val, $stringLabelCache); $parts[] = $symLabel . ': ' . $levelLabel; } return implode('; ', $parts); } /** * Build translation entry lists for one questionnaire. * Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved). */ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { $qStmt = $pdo->prepare(" SELECT q.questionID, q.defaultText, q.type, q.configJson, q.orderIndex FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex "); $qStmt->execute([':id' => $qnID]); $dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $stringKeys = []; $stringEntries = []; $contentEntries = []; $addedSymptomKeys = []; $appKeySet = qdb_app_string_key_set(); foreach ($dbQuestions as $dbQ) { $qOrder = (int)($dbQ['orderIndex'] ?? 0); $qLocal = qdb_question_local_id($dbQ['questionID']); $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; $qKey = qdb_question_key($config, $dbQ['defaultText']); $germanQ = trim($dbQ['defaultText']); if ($qKey !== '' && qdb_is_stable_key($germanQ) && $germanQ === $qKey) { $germanQ = ''; } $contentEntries[] = [ 'key' => $qKey !== '' ? $qKey : $dbQ['defaultText'], 'displayKey' => $qKey !== '' ? $qKey : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal), 'germanText' => $germanQ, 'type' => 'question', 'entityId' => $dbQ['questionID'], 'sortOrder' => $qOrder * 1000, ]; $aoStmt = $pdo->prepare(" SELECT answerOptionID, defaultText, orderIndex FROM answer_option WHERE questionID = :qid ORDER BY orderIndex "); $aoStmt->execute([':qid' => $dbQ['questionID']]); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { $optKey = qdb_option_key($ao); $optLabel = $optKey !== '' ? ($qLocal . ' · ' . $optKey) : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) : ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1))); $contentEntries[] = [ 'key' => $optKey !== '' ? $optKey : $ao['defaultText'], 'displayKey' => $optLabel, 'germanText' => qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']), 'type' => 'answer_option', 'entityId' => $ao['answerOptionID'], 'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), ]; } foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) { if (!empty($config[$field])) { $stringKeys[$config[$field]] = true; } } if (($dbQ['type'] ?? '') === 'glass_scale_question' && !empty($config['symptoms']) && is_array($config['symptoms'])) { $symIdx = 0; foreach ($config['symptoms'] as $s) { $sk = trim((string)$s); if ($sk === '' || isset($appKeySet[$sk]) || isset($addedSymptomKeys[$sk])) { continue; } $addedSymptomKeys[$sk] = true; $parentLabel = $qKey !== '' ? $qKey : $qLocal; $stringEntries[] = [ 'key' => $sk, 'displayKey' => $qLocal . ' · ' . $parentLabel . ' · ' . $sk, 'type' => 'string', 'entityId' => $sk, 'sortOrder' => $qOrder * 1000 + 5 + $symIdx, ]; $symIdx++; } } if ($qKey !== '') { if (!empty($config['noteBefore'])) { $stringKeys[qdb_note_before_key($qKey)] = true; } if (!empty($config['noteAfter'])) { $stringKeys[qdb_note_after_key($qKey)] = true; } } } $stringOrder = 0; $skList = array_keys($stringKeys); sort($skList, SORT_STRING); foreach ($skList as $sk) { if (isset($appKeySet[$sk])) { continue; } $stringEntries[] = [ 'key' => $sk, 'displayKey' => $sk, 'type' => 'string', 'entityId' => $sk, 'sortOrder' => $stringOrder++, ]; } return ['stringEntries' => $stringEntries, 'contentEntries' => $contentEntries]; } /** Attach translation texts and germanText to entry rows. */ function qdb_attach_translation_texts(array &$entries, array $translations): void { foreach ($entries as &$e) { $tr = $translations[$e['entityId']] ?? []; $e['translations'] = (object)$tr; $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); if ($de !== '') { $e['germanText'] = $de; } elseif (!empty($e['germanDefault'])) { $e['germanText'] = $e['germanDefault']; } else { $e['germanText'] = $e['key']; } unset($e['germanDefault']); } unset($e); } /** Load all translation rows for a flat entry list. */ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array { $translations = []; $qIds = array_values(array_filter(array_map( fn($e) => $e['type'] === 'question' ? $e['entityId'] : null, $entries ))); if ($qIds) { $ph = implode(',', array_fill(0, count($qIds), '?')); $stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)"); $stmt->execute($qIds); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { $translations[$r['entityId']][$r['languageCode']] = $r['text']; } } $aoIds = array_values(array_filter(array_map( fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null, $entries ))); if ($aoIds) { $ph = implode(',', array_fill(0, count($aoIds), '?')); $stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)"); $stmt->execute($aoIds); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { $translations[$r['entityId']][$r['languageCode']] = $r['text']; } } $sKeys = array_values(array_filter(array_map( fn($e) => ($e['type'] === 'string' || $e['type'] === 'app_string') ? $e['entityId'] : null, $entries ))); if ($sKeys) { $ph = implode(',', array_fill(0, count($sKeys), '?')); $stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)"); $stmt->execute($sKeys); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { $translations[$r['entityId']][$r['languageCode']] = $r['text']; } } return $translations; } /** * Flat language -> stringKey -> text map for global app UI strings only. * @return array> */ function qdb_build_app_translations_map(PDO $pdo): array { $entries = qdb_app_ui_string_entries_merged($pdo); $translations = qdb_load_translations_for_entries($pdo, $entries); $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; $result = []; foreach ($entries as $e) { $key = $e['key']; foreach ($translations[$e['entityId']] ?? [] as $lang => $text) { $result[$lang][$key] = $text; } if (!isset($result[QDB_SOURCE_LANGUAGE][$key]) && !empty($defaults[$key])) { $result[QDB_SOURCE_LANGUAGE][$key] = $defaults[$key]; } } return $result; } /** * Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings). * Excludes keys from the global app UI catalog. * @return array> */ function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array { $lists = qdb_translation_entry_lists($pdo, $qnID); $entries = array_merge($lists['stringEntries'], $lists['contentEntries']); $translations = $entries ? qdb_load_translations_for_entries($pdo, $entries) : []; $result = []; $stringLabelCache = []; $appDefaults = qdb_app_string_catalog()['germanDefaults'] ?? []; foreach ($entries as $e) { $key = $e['key']; if ($key === '') { continue; } foreach ($translations[$e['entityId']] ?? [] as $lang => $text) { $text = trim((string)$text); if ($text !== '') { $result[$lang][$key] = $text; } } $german = trim($e['germanText'] ?? ''); if ($german === '' && ($e['type'] ?? '') === 'string') { $trDe = trim($translations[$e['entityId']][QDB_SOURCE_LANGUAGE] ?? ''); if ($trDe !== '' && !qdb_is_stable_key($trDe)) { $german = $trDe; } elseif (!empty($appDefaults[$key])) { $german = trim((string)$appDefaults[$key]); } else { $fetched = trim(qdb_string_german_label($pdo, $key, $stringLabelCache)); if ($fetched !== '' && !qdb_is_stable_key($fetched)) { $german = $fetched; } } } if ($german !== '' && !isset($result[QDB_SOURCE_LANGUAGE][$key])) { $result[QDB_SOURCE_LANGUAGE][$key] = $german; } } $qStmt = $pdo->prepare( 'SELECT questionID, defaultText, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex' ); $qStmt->execute([':id' => $qnID]); foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $dbQ) { $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; $qKey = qdb_question_key($config, $dbQ['defaultText']); if ($qKey === '') { continue; } $qGerman = qdb_question_german_label($pdo, $dbQ); if ($qGerman !== '' && !qdb_is_stable_key($qGerman)) { $result[QDB_SOURCE_LANGUAGE][$qKey] = $qGerman; } $noteBefore = trim((string)($config['noteBefore'] ?? '')); if ($noteBefore !== '') { $result[QDB_SOURCE_LANGUAGE][qdb_note_before_key($qKey)] = $noteBefore; $textKey = trim((string)($config['textKey'] ?? '')); if ($textKey !== '') { $result[QDB_SOURCE_LANGUAGE][$textKey] = $noteBefore; } } $noteAfter = trim((string)($config['noteAfter'] ?? '')); if ($noteAfter !== '') { $result[QDB_SOURCE_LANGUAGE][qdb_note_after_key($qKey)] = $noteAfter; } } $aoStmt = $pdo->prepare( 'SELECT answerOptionID, defaultText FROM answer_option WHERE questionID IN ( SELECT questionID FROM question WHERE questionnaireID = :id ) ORDER BY questionID, orderIndex' ); $aoStmt->execute([':id' => $qnID]); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { $optKey = qdb_option_key($ao); if ($optKey === '') { continue; } $optGerman = qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']); if ($optGerman !== '' && !qdb_is_stable_key($optGerman)) { $result[QDB_SOURCE_LANGUAGE][$optKey] = $optGerman; } } return $result; } /** @return array */ function qdb_translation_rows_to_map(array $rows): array { $map = []; foreach ($rows as $row) { if (is_array($row) && isset($row['languageCode'])) { $map[$row['languageCode']] = $row['text'] ?? ''; } } return $map; } /** @return array */ function qdb_fetch_translation_map(PDO $pdo, string $type, string $entityId): array { if ($type === 'question') { $stmt = $pdo->prepare('SELECT languageCode, text FROM question_translation WHERE questionID = :id'); } elseif ($type === 'answer_option') { $stmt = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); } else { $stmt = $pdo->prepare('SELECT languageCode, text FROM string_translation WHERE stringKey = :id'); } $stmt->execute([':id' => $entityId]); return qdb_translation_rows_to_map($stmt->fetchAll(PDO::FETCH_ASSOC)); } function qdb_apply_translation_map(PDO $pdo, string $type, string $entityId, array $map): void { foreach ($map as $lang => $text) { if (!is_string($lang) || $lang === '') { continue; } qdb_put_translation($pdo, $type, $entityId, $lang, (string)$text); } } function qdb_question_local_id(string $questionID, ?string $questionnaireID = null): string { $pos = strrpos($questionID, '__'); return $pos !== false ? substr($questionID, $pos + 2) : $questionID; } /** Column / export header: stable question key, else local id (q1, q2, …). */ function qdb_question_column_label(array $config, string $defaultText, string $questionID, ?string $questionnaireID = null): string { $key = qdb_question_key($config, $defaultText); return $key !== '' ? $key : qdb_question_local_id($questionID, $questionnaireID); } function qdb_make_question_id(string $questionnaireID, string $localId): string { return $questionnaireID . '__' . $localId; } /** Next short id for a new question (q1, q2, …) within one questionnaire. */ function qdb_allocate_question_local_id(PDO $pdo, string $questionnaireID): string { $stmt = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); $stmt->execute([':id' => $questionnaireID]); $max = 0; foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $questionID) { $local = qdb_question_local_id($questionID); if (preg_match('/^q(\d+)$/i', $local, $m)) { $max = max($max, (int)$m[1]); } } return 'q' . ($max + 1); } /** Human-readable Key column label (not the app translation lookup key). */ function qdb_translation_row_label(array $e): string { if (!empty($e['displayKey']) && !preg_match('/^[a-f0-9]{32}$/i', $e['displayKey'])) { return $e['displayKey']; } $type = $e['type'] ?? ''; $key = $e['key'] ?? ''; if ($type === 'question' || $type === 'answer_option') { if ($key !== '') { $short = mb_strlen($key) > 56 ? mb_substr($key, 0, 56) . '…' : $key; return $short; } } $id = $e['entityId'] ?? ''; $pos = strrpos($id, '__'); if ($pos !== false) { return substr($id, $pos + 2); } return $key !== '' ? $key : $id; } /** Export one questionnaire with structure + all translations (portable JSON). */ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { require_once __DIR__ . '/lib/scoring.php'; require_once __DIR__ . '/lib/questionnaire_structure.php'; $qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id'); $qn->execute([':id' => $qnID]); $qnRow = $qn->fetch(PDO::FETCH_ASSOC); if (!$qnRow) { return null; } $qStmt = $pdo->prepare( 'SELECT questionID, defaultText, type, orderIndex, isRequired, configJson FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . ' ORDER BY orderIndex' ); $qStmt->execute([':id' => $qnID]); $questionsOut = []; foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $dbQ) { $localId = qdb_question_local_id($dbQ['questionID'], $qnID); $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; $optionsOut = []; $aoStmt = $pdo->prepare( 'SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . ' ORDER BY orderIndex' ); $aoStmt->execute([':qid' => $dbQ['questionID']]); $qKey = qdb_question_key($config, $dbQ['defaultText']); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { $optKey = qdb_option_key($ao); $optTr = qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']); $optGerman = trim($optTr[QDB_SOURCE_LANGUAGE] ?? ''); if ($optGerman === '' && !qdb_is_stable_key($ao['defaultText'])) { $optGerman = $ao['defaultText']; } $optionsOut[] = [ 'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'], 'defaultText' => $optGerman, 'points' => (int)$ao['points'], 'orderIndex' => (int)$ao['orderIndex'], 'nextQuestionId' => $ao['nextQuestionId'] ?? '', 'translations' => $optTr, ]; } $questionsOut[] = [ 'localId' => $localId, 'questionKey' => $qKey, 'defaultText' => $dbQ['defaultText'], 'type' => $dbQ['type'], 'orderIndex' => (int)$dbQ['orderIndex'], 'isRequired' => (int)$dbQ['isRequired'], 'config' => $config, 'answerOptions' => $optionsOut, 'scoreRules' => qdb_get_score_rules_for_question($pdo, $dbQ['questionID']), 'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']), ]; } $lists = qdb_translation_entry_lists($pdo, $qnID); $stringTranslations = []; if (!empty($lists['stringEntries'])) { $tr = qdb_load_translations_for_entries($pdo, $lists['stringEntries']); foreach ($lists['stringEntries'] as $e) { $stringTranslations[] = [ 'stringKey' => $e['key'], 'translations' => $tr[$e['entityId']] ?? [], ]; } } return [ 'questionnaire' => [ 'questionnaireID' => $qnRow['questionnaireID'], 'name' => $qnRow['name'], 'version' => $qnRow['version'], 'state' => $qnRow['state'], 'orderIndex' => (int)$qnRow['orderIndex'], 'showPoints' => (int)$qnRow['showPoints'], 'conditionJson' => $qnRow['conditionJson'] ?: '{}', 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID), ], 'questions' => $questionsOut, 'stringTranslations' => $stringTranslations, 'translationsFlat' => qdb_build_questionnaire_translations_map($pdo, $qnID), ]; } /** Export every questionnaire as a portable bundle. */ function qdb_export_all_questionnaires_bundle(PDO $pdo): array { $ids = $pdo->query('SELECT questionnaireID FROM questionnaire ORDER BY orderIndex, name') ->fetchAll(PDO::FETCH_COLUMN); $items = []; foreach ($ids as $id) { $item = qdb_export_questionnaire_bundle($pdo, $id); if ($item !== null) { $items[] = $item; } } return [ 'exportVersion' => 2, 'exportedAt' => time(), 'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'questionnaireCount' => count($items), 'questionnaires' => $items, 'scoringProfiles' => qdb_export_scoring_profiles_bundle($pdo), ]; } /** Export all scoring profiles for bundle portability. */ function qdb_export_scoring_profiles_bundle(PDO $pdo): array { require_once __DIR__ . '/lib/scoring.php'; return qdb_list_scoring_profiles($pdo); } /** Remove questionnaire and related rows (full replace import; not per-question retire). */ function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void { $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); $qIds->execute([':id' => $qnID]); $questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN); foreach ($questionIDs as $qid) { $aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :qid'); $aoIds->execute([':qid' => $qid]); foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) { $pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id') ->execute([':id' => $aoid]); } $pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]); $pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]); if (qdb_table_exists($pdo, 'client_answer_submission')) { $pdo->prepare('DELETE FROM client_answer_submission WHERE questionID = :qid') ->execute([':qid' => $qid]); } $pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]); } if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) { $pdo->prepare('DELETE FROM questionnaire_structure_snapshot WHERE questionnaireID = :id') ->execute([':id' => $qnID]); } if (qdb_table_exists($pdo, 'questionnaire_submission')) { $pdo->prepare('DELETE FROM questionnaire_submission WHERE questionnaireID = :id') ->execute([':id' => $qnID]); } $pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]); $pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); $pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); } /** * Import one questionnaire from a bundle item. * @return array{questionnaireID: string, created: bool, replaced: bool} */ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfExists = false): array { $meta = $item['questionnaire'] ?? []; if (empty($meta['name'])) { json_error('MISSING_FIELDS', 'questionnaire.name is required', 400); } $wantedId = trim($meta['questionnaireID'] ?? ''); $chk = $wantedId !== '' ? $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id') : null; if ($chk) { $chk->execute([':id' => $wantedId]); } $exists = $chk && (bool)$chk->fetch(); $replaced = false; if ($exists && $replaceIfExists) { qdb_delete_questionnaire_cascade($pdo, $wantedId); $replaced = true; $qnID = $wantedId; } elseif ($exists) { $qnID = bin2hex(random_bytes(16)); } elseif ($wantedId !== '') { $qnID = $wantedId; } else { $qnID = bin2hex(random_bytes(16)); } $order = (int)($meta['orderIndex'] ?? 0); if ($order === 0) { $order = (int)$pdo->query('SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire')->fetchColumn(); } $condJson = $meta['conditionJson'] ?? '{}'; if (is_array($condJson)) { $condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE); } $catKey = trim($meta['categoryKey'] ?? ''); $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)") ->execute([ ':id' => $qnID, ':n' => trim($meta['name']), ':v' => trim($meta['version'] ?? ''), ':s' => trim($meta['state'] ?? 'draft'), ':o' => $order, ':sp' => (int)($meta['showPoints'] ?? 0), ':cj' => $condJson ?: '{}', ':ck' => $catKey, ]); foreach ($item['questions'] ?? [] as $q) { $localId = trim($q['localId'] ?? ''); if ($localId === '') { $localId = 'q_' . bin2hex(random_bytes(4)); } $questionID = $qnID . '__' . $localId; $config = qdb_parse_config_json($q['config'] ?? $q['configJson'] ?? []); $questionKey = trim((string)($q['questionKey'] ?? $config['questionKey'] ?? '')); $text = trim($q['defaultText'] ?? ''); $qTr = $q['translations'] ?? []; if (is_array($qTr) && isset($qTr[0]['languageCode'])) { $qTr = qdb_translation_rows_to_map($qTr); } if ($questionKey === '' && qdb_is_stable_key($text) && trim($qTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $qTr[QDB_SOURCE_LANGUAGE] !== $text) { $questionKey = $text; $text = trim($qTr[QDB_SOURCE_LANGUAGE]); } if ($questionKey === '') { json_error( 'MISSING_FIELDS', "Question key is required for {$qnID} / {$localId} (stable key [a-z][a-z0-9_]*)", 400 ); } $questionKey = qdb_validate_stable_key($questionKey, 'Question key'); // Duplicate question keys in one questionnaire are allowed (same prompt in different branches). qdb_require_non_empty_german($text, 'Question German text'); $noteBefore = $config['noteBefore'] ?? null; $noteAfter = $config['noteAfter'] ?? null; $config = qdb_normalize_question_config($config, $questionKey, $noteBefore, $noteAfter); $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") ->execute([ ':id' => $questionID, ':qid' => $qnID, ':t' => $text, ':ty' => trim($q['type'] ?? ''), ':o' => (int)($q['orderIndex'] ?? 0), ':r' => (int)($q['isRequired'] ?? 0), ':cj' => $configJson, ]); qdb_upsert_source_translation($pdo, 'question', $questionID, $text); qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); qdb_sync_question_note_strings($pdo, $questionKey, $config); if (!empty($q['scoreRules']) && is_array($q['scoreRules'])) { require_once __DIR__ . '/lib/scoring.php'; qdb_sync_question_score_rules($pdo, $questionID, $q['scoreRules']); } foreach ($q['answerOptions'] ?? [] as $opt) { $optKey = trim((string)($opt['optionKey'] ?? '')); $optGerman = trim($opt['defaultText'] ?? ''); $oTr = $opt['translations'] ?? []; if (is_array($oTr) && isset($oTr[0]['languageCode'])) { $oTr = qdb_translation_rows_to_map($oTr); } if ($optKey === '' && qdb_is_stable_key($optGerman) && trim($oTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $oTr[QDB_SOURCE_LANGUAGE] !== $optGerman) { $optKey = $optGerman; $optGerman = trim($oTr[QDB_SOURCE_LANGUAGE]); } if ($optKey === '' && qdb_is_stable_key($optGerman)) { $optKey = $optGerman; $optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? $optGerman); } if ($optKey === '') { json_error( 'MISSING_FIELDS', "Option key is required for {$qnID} / {$localId} option #" . ((int)($opt['orderIndex'] ?? 0) + 1), 400 ); } $optKey = qdb_validate_stable_key($optKey, 'Option key'); if ($optGerman === '') { $optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? ''); } qdb_require_non_empty_german($optGerman, 'Answer option German text'); $aoId = bin2hex(random_bytes(16)); $pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)") ->execute([ ':id' => $aoId, ':qid' => $questionID, ':t' => $optKey, ':p' => (int)($opt['points'] ?? 0), ':o' => (int)($opt['orderIndex'] ?? 0), ':nq' => trim($opt['nextQuestionId'] ?? ''), ]); qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman); qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []); } } foreach ($item['stringTranslations'] ?? [] as $st) { $sk = trim($st['stringKey'] ?? ''); if ($sk === '') { continue; } $map = $st['translations'] ?? []; if (is_array($map) && isset($map[0]['languageCode'])) { $map = qdb_translation_rows_to_map($map); } qdb_apply_translation_map($pdo, 'string', $sk, is_array($map) ? $map : []); } return [ 'questionnaireID' => $qnID, 'created' => true, 'replaced' => $replaced, ]; } /** One translation row for a portable translations JSON bundle. */ function qdb_translation_entry_to_bundle_row(array $e, ?string $questionnaireID = null, ?string $questionnaireName = null): array { $tr = $e['translations'] ?? []; if ($tr instanceof stdClass) { $tr = (array)$tr; } if (is_array($tr) && isset($tr[0]) && is_array($tr[0]) && isset($tr[0]['languageCode'])) { $tr = qdb_translation_rows_to_map($tr); } $row = [ 'type' => $e['type'], 'entityId' => $e['entityId'], 'key' => $e['key'] ?? '', 'translations' => is_array($tr) ? $tr : [], ]; if (!empty($e['displayKey'])) { $row['displayKey'] = $e['displayKey']; } if ($questionnaireID !== null && $questionnaireID !== '') { $row['questionnaireID'] = $questionnaireID; } if ($questionnaireName !== null && $questionnaireName !== '') { $row['questionnaireName'] = $questionnaireName; } return $row; } /** @return array */ function qdb_normalize_bundle_translations($raw): array { if (!is_array($raw)) { return []; } if (isset($raw[0]) && is_array($raw[0]) && isset($raw[0]['languageCode'])) { return qdb_translation_rows_to_map($raw); } $map = []; foreach ($raw as $lang => $text) { if (is_string($lang) && $lang !== '') { $map[$lang] = (string)$text; } } return $map; } function qdb_translation_entity_exists(PDO $pdo, string $type, string $entityId): bool { if ($type === 'string' || $type === 'app_string') { return true; } if ($type === 'question') { $stmt = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id LIMIT 1'); } elseif ($type === 'answer_option') { $stmt = $pdo->prepare('SELECT 1 FROM answer_option WHERE answerOptionID = :id LIMIT 1'); } else { return false; } $stmt->execute([':id' => $entityId]); return (bool)$stmt->fetchColumn(); } /** Export all translations (languages + flat entries) as portable JSON. */ function qdb_export_translations_bundle(PDO $pdo): array { $languages = $pdo->query('SELECT languageCode, name FROM language ORDER BY languageCode') ->fetchAll(PDO::FETCH_ASSOC); if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) { array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']); } qdb_ensure_source_language($pdo); $exportEntries = []; $appEntries = qdb_app_ui_string_entries_for_website($pdo); $appTranslations = qdb_load_translations_for_entries($pdo, $appEntries); qdb_attach_translation_texts($appEntries, $appTranslations); foreach ($appEntries as $e) { $exportEntries[] = qdb_translation_entry_to_bundle_row($e); } $qnRows = $pdo->query('SELECT questionnaireID, name FROM questionnaire ORDER BY orderIndex, name') ->fetchAll(PDO::FETCH_ASSOC); foreach ($qnRows as $qn) { $lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']); $entries = array_merge($lists['stringEntries'], $lists['contentEntries']); if (!$entries) { continue; } $translations = qdb_load_translations_for_entries($pdo, $entries); qdb_attach_translation_texts($entries, $translations); foreach ($entries as $e) { $exportEntries[] = qdb_translation_entry_to_bundle_row( $e, $qn['questionnaireID'], $qn['name'] ); } } return [ 'exportVersion' => 1, 'exportedAt' => time(), 'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'languageCount' => count($languages), 'languages' => $languages, 'entryCount' => count($exportEntries), 'entries' => $exportEntries, ]; } /** Import translations bundle (upsert languages and translation rows). */ function qdb_import_translations_bundle(PDO $pdo, array $bundle): array { $entries = $bundle['entries'] ?? null; if (!is_array($entries)) { json_error('MISSING_FIELDS', 'bundle.entries array is required', 400); } qdb_ensure_source_language($pdo); foreach ($bundle['languages'] ?? [] as $langRow) { if (!is_array($langRow)) { continue; } $lc = trim($langRow['languageCode'] ?? ''); $name = trim($langRow['name'] ?? ''); if ($lc === '' || $lc === QDB_SOURCE_LANGUAGE) { continue; } $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name') ->execute([':lc' => $lc, ':n' => $name]); } $validTypes = ['question', 'answer_option', 'string', 'app_string']; $imported = 0; $skipped = 0; foreach ($entries as $entry) { if (!is_array($entry)) { $skipped++; continue; } $type = $entry['type'] ?? ''; $entityId = trim((string)($entry['entityId'] ?? '')); if (!in_array($type, $validTypes, true) || $entityId === '') { $skipped++; continue; } if (!qdb_translation_entity_exists($pdo, $type, $entityId)) { $skipped++; continue; } $map = qdb_normalize_bundle_translations($entry['translations'] ?? []); if ($map) { qdb_apply_translation_map($pdo, $type, $entityId, $map); $imported++; } else { $skipped++; } } return [ 'imported' => $imported, 'skipped' => $skipped, 'total' => count($entries), ]; } /** * Remove translation rows from the database. * By default keeps German source language rows and the de language entry. */ function qdb_delete_all_translations(PDO $pdo, bool $keepSourceLanguage = true): array { if ($keepSourceLanguage) { $de = QDB_SOURCE_LANGUAGE; $count = static function (PDO $pdo, string $table) use ($de): int { $stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE languageCode != :de"); $stmt->execute([':de' => $de]); return (int)$stmt->fetchColumn(); }; $questionCount = $count($pdo, 'question_translation'); $answerCount = $count($pdo, 'answer_option_translation'); $stringCount = $count($pdo, 'string_translation'); $langStmt = $pdo->prepare('SELECT COUNT(*) FROM language WHERE languageCode != :de'); $langStmt->execute([':de' => $de]); $languageCount = (int)$langStmt->fetchColumn(); $pdo->prepare('DELETE FROM question_translation WHERE languageCode != :de')->execute([':de' => $de]); $pdo->prepare('DELETE FROM answer_option_translation WHERE languageCode != :de')->execute([':de' => $de]); $pdo->prepare('DELETE FROM string_translation WHERE languageCode != :de')->execute([':de' => $de]); $pdo->prepare('DELETE FROM language WHERE languageCode != :de')->execute([':de' => $de]); } else { $questionCount = (int)$pdo->query('SELECT COUNT(*) FROM question_translation')->fetchColumn(); $answerCount = (int)$pdo->query('SELECT COUNT(*) FROM answer_option_translation')->fetchColumn(); $stringCount = (int)$pdo->query('SELECT COUNT(*) FROM string_translation')->fetchColumn(); $languageCount = (int)$pdo->query('SELECT COUNT(*) FROM language')->fetchColumn(); $pdo->exec('DELETE FROM question_translation'); $pdo->exec('DELETE FROM answer_option_translation'); $pdo->exec('DELETE FROM string_translation'); $pdo->exec('DELETE FROM language'); } qdb_ensure_source_language($pdo); return [ 'question' => $questionCount, 'answer_option' => $answerCount, 'string' => $stringCount, 'languages' => $languageCount, 'keptSource' => $keepSourceLanguage, ]; } /** Import a full export bundle. */ function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array { $items = $bundle['questionnaires'] ?? []; if (!is_array($items) || !$items) { json_error('MISSING_FIELDS', 'bundle.questionnaires array is required', 400); } foreach ($bundle['appStringTranslations'] ?? [] as $st) { if (!is_array($st)) { continue; } $sk = trim($st['stringKey'] ?? ''); if ($sk === '') { continue; } $map = qdb_normalize_bundle_translations($st['translations'] ?? []); if (!$map) { continue; } if (isset($map[QDB_SOURCE_LANGUAGE])) { qdb_set_app_string_german_label($pdo, $sk, $map[QDB_SOURCE_LANGUAGE]); unset($map[QDB_SOURCE_LANGUAGE]); } if ($map) { qdb_apply_translation_map($pdo, 'app_string', $sk, $map); } } $results = []; foreach ($items as $item) { $results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists); } if (!empty($bundle['scoringProfiles']) && is_array($bundle['scoringProfiles'])) { qdb_import_scoring_profiles_bundle($pdo, $bundle['scoringProfiles'], $replaceIfExists); } return [ 'imported' => count($results), 'results' => $results, ]; } /** Import scoring profiles from a questionnaire bundle. */ function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $replaceIfExists = false): void { require_once __DIR__ . '/lib/scoring.php'; foreach ($profiles as $profile) { if (!is_array($profile)) { continue; } $wantedId = trim($profile['profileID'] ?? ''); $name = trim($profile['name'] ?? ''); if ($name === '') { continue; } $exists = false; if ($wantedId !== '') { $chk = $pdo->prepare('SELECT 1 FROM scoring_profile WHERE profileID = :id'); $chk->execute([':id' => $wantedId]); $exists = (bool)$chk->fetch(); } if ($exists && $replaceIfExists) { $pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $wantedId]); $profileID = $wantedId; } elseif ($exists) { $profileID = bin2hex(random_bytes(16)); } else { $profileID = $wantedId !== '' ? $wantedId : bin2hex(random_bytes(16)); } $now = time(); $pdo->prepare( 'INSERT INTO scoring_profile (profileID, name, description, isActive, greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)' )->execute([ ':id' => $profileID, ':name' => $name, ':desc' => trim($profile['description'] ?? ''), ':act' => !empty($profile['isActive']) ? 1 : 0, ':gmin' => (int)($profile['greenMin'] ?? 0), ':gmax' => (int)($profile['greenMax'] ?? 12), ':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)), ':ymax' => (int)($profile['yellowMax'] ?? 36), ':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)), ':ca' => (int)($profile['createdAt'] ?? $now), ':ua' => (int)($profile['updatedAt'] ?? $now), ]); $members = []; foreach ($profile['questionnaires'] ?? [] as $idx => $row) { if (!is_array($row)) { continue; } $qnID = trim((string)($row['questionnaireID'] ?? '')); if ($qnID === '') { continue; } $chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id'); $chk->execute([':id' => $qnID]); if (!$chk->fetch()) { continue; } $members[] = [ 'questionnaireID' => $qnID, 'weight' => (float)($row['weight'] ?? 1.0), 'orderIndex' => (int)($row['orderIndex'] ?? $idx), ]; } if ($members !== []) { qdb_sync_profile_questionnaire_members($pdo, $profileID, $members); } } } function qdb_sync_profile_questionnaire_members(PDO $pdo, string $profileID, array $members): void { $pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid') ->execute([':pid' => $profileID]); $ins = $pdo->prepare( 'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex) VALUES (:pid, :qn, :w, :o)' ); foreach ($members as $m) { $ins->execute([ ':pid' => $profileID, ':qn' => $m['questionnaireID'], ':w' => $m['weight'], ':o' => $m['orderIndex'], ]); } } // --- AES-256-CBC: IV(16) + CIPHERTEXT --- function aes256_cbc_encrypt_bytes(string $plain, string $key): string { $key = str_pad(substr($key, 0, 32), 32, "\0"); $iv = random_bytes(16); $cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); if ($cipher === false) throw new Exception('openssl_encrypt failed'); return $iv . $cipher; } function aes256_cbc_decrypt_bytes(string $data, string $key): string { if (strlen($data) < 16) throw new Exception('cipher too short'); $key = str_pad(substr($key, 0, 32), 32, "\0"); $iv = substr($data, 0, 16); $ct = substr($data, 16); $plain = openssl_decrypt($ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); if ($plain === false) throw new Exception('openssl_decrypt failed'); return $plain; }