From 36b6d63266c6256c3a62356cbc0557c9edb63949 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Wed, 27 May 2026 19:38:49 +0200 Subject: [PATCH] added categoryKey to questionnaire schema and updated related functions for questionnaire handling; enhanced API to fetch client answers --- common.php | 6 +- data/app_ui_strings.json | 18 +++ db_init.php | 15 +- docs/android-questionnaire-api.md | 52 ++++++ handlers/app_questionnaires.php | 53 +++++- handlers/questionnaires.php | 16 +- lib/app_answers.php | 257 ++++++++++++++++++++++++++++++ schema.sql | 3 +- website/js/pages/editor.js | 21 ++- 9 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 lib/app_answers.php diff --git a/common.php b/common.php index 224ca39..2650bdf 100644 --- a/common.php +++ b/common.php @@ -1172,8 +1172,9 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE $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)") + $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']), @@ -1182,6 +1183,7 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE ':o' => $order, ':sp' => (int)($meta['showPoints'] ?? 0), ':cj' => $condJson ?: '{}', + ':ck' => $catKey, ]); foreach ($item['questions'] ?? [] as $q) { diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index a5b0dfb..9c34934 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -39,7 +39,16 @@ "july", "june", "lay", + "consultation_unlock_requires_rhs", "locked", + "questionnaire_chip_edit", + "questionnaire_chip_edit_pending", + "questionnaire_group_closure", + "questionnaire_group_consultation", + "questionnaire_group_demographics", + "questionnaire_group_followup", + "questionnaire_group_health_interview", + "questionnaire_group_integration", "login_btn", "login_failed_with_reason", "login_required", @@ -127,7 +136,16 @@ "july": "Juli", "june": "Juni", "lay": "liegen.", + "consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS", "locked": "Gesperrt", + "questionnaire_chip_edit": "Bearbeiten", + "questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend", + "questionnaire_group_closure": "Abschluss", + "questionnaire_group_consultation": "Beratung", + "questionnaire_group_demographics": "Demografie", + "questionnaire_group_followup": "Nachbefragung", + "questionnaire_group_health_interview": "Gesundheitsinterview", + "questionnaire_group_integration": "Integration (IPL)", "login_btn": "Login", "login_failed_with_reason": "Login fehlgeschlagen: {reason}", "login_required": "Bitte zuerst einloggen", diff --git a/db_init.php b/db_init.php index 54a4266..7886281 100644 --- a/db_init.php +++ b/db_init.php @@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php'; define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 3); +define('QDB_VERSION', 4); /** * Decrypt the master DB file into a temp SQLite file, or create a fresh one @@ -72,6 +72,19 @@ function qdb_open(bool $writable = false): array { if ($currentVersion < QDB_VERSION) { $pdo->exec("PRAGMA foreign_keys = OFF;"); $pdo->exec($sql); + if ($currentVersion < 4) { + $cols = $pdo->query("PRAGMA table_info(questionnaire)")->fetchAll(PDO::FETCH_ASSOC); + $hasCategory = false; + foreach ($cols as $col) { + if (($col['name'] ?? '') === 'categoryKey') { + $hasCategory = true; + break; + } + } + if (!$hasCategory) { + $pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); + } + } $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); $pdo->exec("PRAGMA foreign_keys = ON;"); } diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 8894e1b..004d15e 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -149,6 +149,7 @@ Fields: - `name`: display/admin name. - `showPoints`: whether the app may show the points result. - `condition`: JSON condition object. Empty conditions are returned as `{}`. +- `categoryKey` (optional): grouping key for the opening screen; labels come from app UI strings (e.g. `questionnaire_group_health_interview`). Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`. @@ -207,6 +208,57 @@ Question fields: Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received. +## Download Client Answers (restore on device) + +`GET /api/app_questionnaires?clientCode=&answers=1` + +Allowed roles: `coach`, `supervisor`, `admin` (coach must be assigned to the client). + +Headers: + +```http +Authorization: Bearer +``` + +Success (encrypted envelope, same as `?clients=1`): + +```json +{ + "ok": true, + "data": { + "encrypted": true, + "payload": { "...": "..." } + } +} +``` + +Decrypted `data`: + +```json +{ + "clientCode": "K008", + "questionnaires": [ + { + "questionnaireID": "questionnaire_2_rhs", + "completedAt": 1710000000, + "sumPoints": 12, + "answers": [ + { "questionID": "q1", "answerOptionKey": "consent_signed" }, + { "questionID": "languages_spoken", "answerOptionKey": "language_arabic" }, + { "questionID": "languages_spoken", "answerOptionKey": "language_english" } + ] + } + ] +} +``` + +Notes: + +- Returns all `completed_questionnaire` rows for the client. +- `answers` mirrors the POST submit shape (short `questionID`, glass-scale symptoms as separate entries, multi-checkbox as multiple rows with the same `questionID`). +- On the server, multi-checkbox answers are stored in one `client_answer` row (`freeTextValue` JSON array); export expands them for the app importer. +- Use on **client load** to restore answers after upload or on a fresh install. + ## Fetch Assigned Clients `GET /api/app_questionnaires?clients=1` diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 665ba68..b845d74 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -5,6 +5,7 @@ * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen). * GET ?clients=1 -> list of clients assigned to the authenticated coach + * GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted) * POST -> submit interview answers for a client (coach / supervisor / admin). * Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token). @@ -69,12 +70,15 @@ if ($method === 'POST') { ); $qRows->execute([':qn' => $qnID]); $shortIdMap = []; // shortId -> fullQuestionID + $shortIdToType = []; // shortId -> layout type $freeTextMaxLen = []; // fullQuestionID -> maxLength $symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID); foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) { $fullId = $qRow['questionID']; $parts = explode('__', $fullId); - $shortIdMap[end($parts)] = $fullId; + $short = end($parts); + $shortIdMap[$short] = $fullId; + $shortIdToType[$short] = $qRow['type'] ?? ''; if (($qRow['type'] ?? '') === 'free_text') { $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000)); @@ -111,6 +115,9 @@ if ($method === 'POST') { answeredAt = excluded.answeredAt "); + require_once __DIR__ . '/../lib/app_answers.php'; + $answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap); + foreach ($answers as $a) { $shortId = trim($a['questionID'] ?? ''); if ($shortId === '') continue; @@ -149,6 +156,13 @@ if ($method === 'POST') { $opt = $optionMap[$fullQID][(string)$optionKey]; $answerOptionID = $opt['answerOptionID']; $sumPoints += $opt['points']; + } elseif (($shortIdToType[$shortId] ?? '') === 'multi_check_box_question' + && $freeTextValue !== null && $freeTextValue !== '') { + foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) { + if (isset($optionMap[$fullQID][$mk])) { + $sumPoints += $optionMap[$fullQID][$mk]['points']; + } + } } $answerInsert->execute([ @@ -218,6 +232,34 @@ if ($method !== 'GET') { $qnID = $_GET['id'] ?? ''; $translations = $_GET['translations'] ?? ''; $fetchClients = $_GET['clients'] ?? ''; +$fetchAnswers = $_GET['answers'] ?? ''; +$clientCode = trim($_GET['clientCode'] ?? ''); + +if ($fetchAnswers) { + require_role(['admin', 'supervisor', 'coach'], $tokenRec); + if ($clientCode === '') { + qdb_discard($tmpDb, $lockFp); + json_error('MISSING_PARAM', 'clientCode query param required', 400); + } + + [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + $clStmt = $pdo->prepare( + "SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" + ); + $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); + if (!$clStmt->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Client not found or not authorized', 404); + } + + require_once __DIR__ . '/../lib/app_answers.php'; + $questionnaires = qdb_export_app_client_answers_bundle($pdo, $clientCode); + qdb_discard($tmpDb, $lockFp); + json_success_sensitive([ + 'clientCode' => $clientCode, + 'questionnaires' => $questionnaires, + ], $tokenRec['_token']); +} if ($fetchClients) { require_role(['coach'], $tokenRec); @@ -364,7 +406,7 @@ if ($qnID) { // Default: ordered questionnaire list $rows = $pdo->query(" - SELECT questionnaireID, name, showPoints, conditionJson + SELECT questionnaireID, name, showPoints, conditionJson, categoryKey FROM questionnaire WHERE state = 'active' ORDER BY orderIndex @@ -372,12 +414,17 @@ $rows = $pdo->query(" $list = []; foreach ($rows as $r) { - $list[] = [ + $item = [ 'id' => $r['questionnaireID'], 'name' => $r['name'], 'showPoints' => (bool)(int)$r['showPoints'], 'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(), ]; + $cat = trim($r['categoryKey'] ?? ''); + if ($cat !== '') { + $item['categoryKey'] = $cat; + } + $list[] = $item; } qdb_discard($tmpDb, $lockFp); diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 33ec120..5e49f9c 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -8,7 +8,7 @@ case 'GET': [$pdo, $tmpDb, $lockFp] = qdb_open(false); $rows = $pdo->query(" SELECT q.questionnaireID, q.name, q.version, q.state, - q.orderIndex, q.showPoints, q.conditionJson, + q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey, COUNT(qu.questionID) AS questionCount FROM questionnaire q LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID @@ -66,10 +66,11 @@ case 'POST': if ($order === 0) { $order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn(); } - $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson) - VALUES (:id, :n, :v, :s, :o, :sp, :cj)") + $catKey = trim($body['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' => $id, ':n' => $name, ':v' => $version, ':s' => $state, - ':o' => $order, ':sp' => $showPts, ':cj' => $condJson]); + ':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey]); qdb_save($tmpDb, $lockFp); json_success(['questionnaire' => [ 'questionnaireID' => $id, 'name' => $name, 'version' => $version, @@ -106,12 +107,15 @@ case 'PUT': $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $showPts = (int)($body['showPoints'] ?? $row['showPoints']); $condJson = $body['conditionJson'] ?? $row['conditionJson']; + $catKey = array_key_exists('categoryKey', $body) + ? trim((string)$body['categoryKey']) + : trim($row['categoryKey'] ?? ''); $pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s, - orderIndex = :o, showPoints = :sp, conditionJson = :cj + orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck WHERE questionnaireID = :id") ->execute([':n' => $name, ':v' => $version, ':s' => $state, - ':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]); + ':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey, ':id' => $id]); qdb_save($tmpDb, $lockFp); json_success(['questionnaire' => [ 'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state, diff --git a/lib/app_answers.php b/lib/app_answers.php new file mode 100644 index 0000000..4539c18 --- /dev/null +++ b/lib/app_answers.php @@ -0,0 +1,257 @@ + */ +function qdb_decode_multi_check_values(?string $raw): array { + if ($raw === null || trim($raw) === '') { + return []; + } + $trimmed = trim($raw); + if ($trimmed[0] === '[') { + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + $out = []; + foreach ($decoded as $v) { + $s = trim((string)$v); + if ($s !== '') { + $out[] = $s; + } + } + return $out; + } + } + $sep = str_contains($trimmed, ',') ? ',' : (str_contains($trimmed, ';') ? ';' : null); + if ($sep !== null) { + $parts = []; + foreach (explode($sep, $trimmed) as $p) { + $s = trim($p, " \t\n\r\0\x0B\"'"); + if ($s !== '') { + $parts[] = $s; + } + } + return $parts; + } + return [$trimmed]; +} + +/** + * Group raw POST answer rows by question short id; merge multi_check option keys. + * + * @param array $shortIdToType question localId => layout type + * @return list> + */ +function qdb_group_app_submit_answers(array $answers, array $shortIdToType, array $symptomParentMap): array { + $grouped = []; + $order = []; + + foreach ($answers as $a) { + if (!is_array($a)) { + continue; + } + $shortId = trim($a['questionID'] ?? ''); + if ($shortId === '') { + continue; + } + + if (isset($symptomParentMap[$shortId])) { + $order[] = $shortId; + $grouped[$shortId] = $a; + continue; + } + + $type = $shortIdToType[$shortId] ?? ''; + if ($type === 'multi_check_box_question') { + if (!isset($grouped[$shortId])) { + $order[] = $shortId; + $grouped[$shortId] = [ + 'questionID' => $shortId, + 'multiOptionKeys' => [], + 'answeredAt' => $a['answeredAt'] ?? null, + ]; + } + $key = trim((string)($a['answerOptionKey'] ?? '')); + if ($key !== '') { + $grouped[$shortId]['multiOptionKeys'][$key] = true; + } + continue; + } + + $order[] = $shortId; + $grouped[$shortId] = $a; + } + + $out = []; + foreach ($order as $shortId) { + $row = $grouped[$shortId]; + if (!empty($row['multiOptionKeys'])) { + $keys = array_keys($row['multiOptionKeys']); + $out[] = [ + 'questionID' => $shortId, + 'freeTextValue' => qdb_encode_multi_check_values($keys), + 'answeredAt' => $row['answeredAt'] ?? null, + ]; + } else { + unset($row['multiOptionKeys']); + $out[] = $row; + } + } + return $out; +} + +/** + * Export one client's answers for one questionnaire into app submit shape. + * + * @return array{answers: list>, sumPoints: int, completedAt: ?int} + */ +function qdb_export_app_questionnaire_answers( + PDO $pdo, + string $clientCode, + string $qnID +): array { + $qStmt = $pdo->prepare( + "SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn" + ); + $qStmt->execute([':qn' => $qnID]); + $shortToFull = []; + $fullToShort = []; + $fullToType = []; + $symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID); + + foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $full = $row['questionID']; + $parts = explode('__', $full); + $short = end($parts); + $shortToFull[$short] = $full; + $fullToShort[$full] = $short; + $fullToType[$full] = $row['type'] ?? ''; + } + + $aoStmt = $pdo->prepare(" + SELECT ao.answerOptionID, ao.questionID, ao.defaultText + FROM answer_option ao + JOIN question q ON q.questionID = ao.questionID + WHERE q.questionnaireID = :qn + "); + $aoStmt->execute([':qn' => $qnID]); + $optionIdToKey = []; + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionIdToKey[$ao['answerOptionID']] = $ao['defaultText']; + } + + $caStmt = $pdo->prepare(" + SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue, ca.answeredAt + FROM client_answer ca + JOIN question q ON q.questionID = ca.questionID + WHERE ca.clientCode = :cc AND q.questionnaireID = :qn + "); + $caStmt->execute([':cc' => $clientCode, ':qn' => $qnID]); + $rows = $caStmt->fetchAll(PDO::FETCH_ASSOC); + + $export = []; + foreach ($rows as $row) { + $fullQid = $row['questionID']; + $shortId = $fullToShort[$fullQid] ?? ''; + if ($shortId === '') { + continue; + } + $type = $fullToType[$fullQid] ?? ''; + + if ($type === 'glass_scale_question' && ($row['freeTextValue'] ?? '') !== '') { + $decoded = json_decode($row['freeTextValue'], true); + if (is_array($decoded)) { + foreach ($decoded as $symptomKey => $label) { + $sk = trim((string)$symptomKey); + $lab = trim((string)$label); + if ($sk !== '' && $lab !== '') { + $export[] = [ + 'questionID' => $sk, + 'answerOptionKey' => $lab, + ]; + } + } + } + continue; + } + + if ($type === 'multi_check_box_question') { + foreach (qdb_decode_multi_check_values($row['freeTextValue'] ?? null) as $key) { + $export[] = [ + 'questionID' => $shortId, + 'answerOptionKey' => $key, + ]; + } + if ($export === [] && !empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) { + $export[] = [ + 'questionID' => $shortId, + 'answerOptionKey' => $optionIdToKey[$row['answerOptionID']], + ]; + } + continue; + } + + $entry = ['questionID' => $shortId]; + if (!empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) { + $entry['answerOptionKey'] = $optionIdToKey[$row['answerOptionID']]; + } + if (isset($row['freeTextValue']) && $row['freeTextValue'] !== null && $row['freeTextValue'] !== '') { + $entry['freeTextValue'] = $row['freeTextValue']; + } + if (isset($row['numericValue']) && $row['numericValue'] !== null && $row['numericValue'] !== '') { + $entry['numericValue'] = (float)$row['numericValue']; + } + if (!empty($row['answeredAt'])) { + $entry['answeredAt'] = (int)$row['answeredAt']; + } + $export[] = $entry; + } + + $cq = $pdo->prepare( + "SELECT sumPoints, completedAt FROM completed_questionnaire + WHERE clientCode = :cc AND questionnaireID = :qn" + ); + $cq->execute([':cc' => $clientCode, ':qn' => $qnID]); + $meta = $cq->fetch(PDO::FETCH_ASSOC) ?: []; + + return [ + 'answers' => $export, + 'sumPoints' => isset($meta['sumPoints']) ? (int)$meta['sumPoints'] : 0, + 'completedAt' => isset($meta['completedAt']) ? (int)$meta['completedAt'] : null, + ]; +} + +/** + * Export all completed questionnaires for a client (coach RBAC must be applied by caller). + * + * @return list> + */ +function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): array { + $stmt = $pdo->prepare( + "SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc" + ); + $stmt->execute([':cc' => $clientCode]); + $items = []; + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) { + $block = qdb_export_app_questionnaire_answers($pdo, $clientCode, $qnID); + $items[] = [ + 'questionnaireID' => $qnID, + 'completedAt' => $block['completedAt'], + 'sumPoints' => $block['sumPoints'], + 'answers' => $block['answers'], + ]; + } + return $items; +} diff --git a/schema.sql b/schema.sql index 342ed6b..090b012 100644 --- a/schema.sql +++ b/schema.sql @@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS questionnaire ( state TEXT NOT NULL DEFAULT '', orderIndex INTEGER NOT NULL DEFAULT 0, showPoints INTEGER NOT NULL DEFAULT 0, - conditionJson TEXT NOT NULL DEFAULT '{}' + conditionJson TEXT NOT NULL DEFAULT '{}', + categoryKey TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS question ( diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index b159499..da8e2cd 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -279,7 +279,8 @@ function configFormHTML(layout, config, prefix) {
-
`; + + ${stringSpinnerOtherSectionHTML(config, prefix)}`; break; case 'glass_scale_question': { const scaleType = config.scaleType || 'glass'; @@ -426,6 +427,13 @@ function readConfigFromForm(layout, prefix) { case 'multi_check_box_question': { const ms = parseInt(val('minSelection') || '0', 10); if (ms > 0) config.minSelection = ms; + const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked; + if (otherEnabled) { + const ok = val('otherOptionKey') || 'other_option'; + const on = val('otherNextQuestionId'); + if (ok) config.otherOptionKey = ok; + if (on) config.otherNextQuestionId = on; + } break; } case 'glass_scale_question': { @@ -534,6 +542,10 @@ function renderEditor() { Show points +
+ + +
${editable ? `
@@ -617,6 +629,7 @@ async function saveMeta() { const state = document.getElementById('metaState').value; const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10); const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0; + const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim(); let conditionJson = '{}'; const condEl = document.getElementById('metaCondition'); if (condEl) { @@ -632,7 +645,7 @@ async function saveMeta() { try { if (isNew) { - const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson }); + const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson, categoryKey }); if (data.success) { questionnaire = data.questionnaire; isNew = false; @@ -642,10 +655,10 @@ async function saveMeta() { } else { const data = await apiPut('questionnaires.php', { questionnaireID: questionnaire.questionnaireID, - name, version, state, orderIndex, showPoints, conditionJson + name, version, state, orderIndex, showPoints, conditionJson, categoryKey }); if (data.success) { - questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson }; + questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey }; document.getElementById('editorTitle').textContent = name; showToast('Saved', 'success'); }