= 2) { $q = $value[0]; if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) { $value = substr($value, 1, -1); } } $existing = getenv($name); if ($existing !== false && $existing !== '') { continue; } putenv("$name=$value"); $_ENV[$name] = $value; } } qdb_load_dotenv(__DIR__ . '/.env'); // --- MASTER-KEY (Server-seitig zum Speichern der DB) --- // Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String) oder Fallback auf bisherigen Wert. function get_master_key_bytes(): string { $env = getenv('QDB_MASTER_KEY'); if (!$env || $env === '') { throw new RuntimeException('QDB_MASTER_KEY environment variable is not set. Cannot proceed without a master key.'); } $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_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, ':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): ?array { require_once __DIR__ . '/db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(false); $stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now"); $stmt->execute([':t' => $token, ':now' => time()]); $row = $stmt->fetch(PDO::FETCH_ASSOC); qdb_discard($tmpDb, $lockFp); if (!$row) return null; // 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]); qdb_save($tmpDb, $lockFp); } // --- 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) { http_response_code(401); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(["error" => "Missing Bearer token"]); exit; } $rec = token_get_record($token); if (!$rec) { http_response_code(403); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(["error" => "Invalid or expired token"]); exit; } if (!empty($rec['temp'])) { http_response_code(403); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(["error" => "Password change required before access"]); exit; } $rec['_token'] = $token; return $rec; } function require_role(array $allowed, array $tokenRecord): void { $role = $tokenRecord['role'] ?? ''; if (!in_array($role, $allowed, true)) { http_response_code(403); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(["error" => "Insufficient permissions"]); exit; } } /** * 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'). */ function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'): array { $role = $tokenRecord['role'] ?? ''; $entityID = $tokenRecord['entityID'] ?? ''; switch ($role) { case 'admin': return ['1=1', []]; case 'supervisor': return [ "$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)", [':rbac_eid' => $entityID] ]; case 'coach': return [ "$clientAlias.coachID = :rbac_eid", [':rbac_eid' => $entityID] ]; default: return ['0=1', []]; } } // --- 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(): array { static $catalog = null; if ($catalog !== null) { return $catalog; } $path = __DIR__ . '/data/app_ui_strings.json'; 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_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; } /** * 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) { $entries[] = [ 'key' => $key, 'displayKey' => $key, 'type' => 'app_string', 'entityId' => $key, 'sortOrder' => $order++, 'germanDefault' => $defaults[$key] ?? $key, ]; } return $entries; } /** * 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) { if ($dbQ['defaultText'] !== '') { $keys[$dbQ['defaultText']] = true; } $config = json_decode($dbQ['configJson'] ?: '{}', 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(): array { $dev = qdb_dev_only_app_string_key_set(); $entries = []; foreach (qdb_app_ui_string_entries() 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]); if ($lang === QDB_SOURCE_LANGUAGE) { $pdo->prepare("UPDATE answer_option SET defaultText = :t WHERE answerOptionID = :id") ->execute([':t' => $text, ':id' => $id]); } } 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); } } /** * 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.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 = []; foreach ($dbQuestions as $dbQ) { $qOrder = (int)($dbQ['orderIndex'] ?? 0); $qLocal = qdb_question_local_id($dbQ['questionID']); $contentEntries[] = [ 'key' => $dbQ['defaultText'], 'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal, '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) { $optLabel = preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) : ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1)); $contentEntries[] = [ 'key' => $ao['defaultText'], 'displayKey' => $optLabel, 'type' => 'answer_option', 'entityId' => $ao['answerOptionID'], 'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), ]; } $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { if (!empty($config[$field])) { $stringKeys[$config[$field]] = true; } } if (!empty($config['symptoms']) && is_array($config['symptoms'])) { foreach ($config['symptoms'] as $s) { $stringKeys[$s] = true; } } } $appKeySet = qdb_app_string_key_set(); $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(); $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']); if (!$entries) { return []; } $translations = qdb_load_translations_for_entries($pdo, $entries); $result = []; foreach ($entries as $e) { $key = $e['key']; foreach ($translations[$e['entityId']] ?? [] as $lang => $text) { $result[$lang][$key] = $text; } } 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; } 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 { $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 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 ORDER BY orderIndex "); $aoStmt->execute([':qid' => $dbQ['questionID']]); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { $optionsOut[] = [ 'defaultText' => $ao['defaultText'], 'points' => (int)$ao['points'], 'orderIndex' => (int)$ao['orderIndex'], 'nextQuestionId' => $ao['nextQuestionId'] ?? '', 'translations' => qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']), ]; } $questionsOut[] = [ 'localId' => $localId, 'defaultText' => $dbQ['defaultText'], 'type' => $dbQ['type'], 'orderIndex' => (int)$dbQ['orderIndex'], 'isRequired' => (int)$dbQ['isRequired'], 'config' => $config, 'answerOptions' => $optionsOut, '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'] ?: '{}', ], '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' => 1, 'exportedAt' => time(), 'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'questionnaireCount' => count($items), 'questionnaires' => $items, ]; } /** Remove questionnaire and related rows (no client_answer cleanup for other qns). */ 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]); $pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]); } $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); } $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson) VALUES (:id, :n, :v, :s, :o, :sp, :cj)") ->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 ?: '{}', ]); foreach ($item['questions'] ?? [] as $q) { $localId = trim($q['localId'] ?? ''); if ($localId === '') { $localId = 'q_' . bin2hex(random_bytes(4)); } $questionID = $qnID . '__' . $localId; $text = trim($q['defaultText'] ?? ''); qdb_require_non_empty_german($text, 'Question German text'); $config = $q['config'] ?? $q['configJson'] ?? []; $configJson = is_string($config) ? $config : 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, ]); $qTr = $q['translations'] ?? []; if (is_array($qTr) && isset($qTr[0]['languageCode'])) { $qTr = qdb_translation_rows_to_map($qTr); } qdb_upsert_source_translation($pdo, 'question', $questionID, $text); qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); foreach ($q['answerOptions'] ?? [] as $opt) { $optText = trim($opt['defaultText'] ?? ''); qdb_require_non_empty_german($optText, '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' => $optText, ':p' => (int)($opt['points'] ?? 0), ':o' => (int)($opt['orderIndex'] ?? 0), ':nq' => trim($opt['nextQuestionId'] ?? ''), ]); $oTr = $opt['translations'] ?? []; if (is_array($oTr) && isset($oTr[0]['languageCode'])) { $oTr = qdb_translation_rows_to_map($oTr); } qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optText); 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, ]; } /** 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); } $results = []; foreach ($items as $item) { $results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists); } return [ 'imported' => count($results), 'results' => $results, ]; } // --- 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; }