added categoryKey to questionnaire schema and updated related functions for questionnaire handling; enhanced API to fetch client answers

This commit is contained in:
2026-05-27 19:38:49 +02:00
parent 9c887537e8
commit 36b6d63266
9 changed files with 424 additions and 17 deletions

View File

@ -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) {

View File

@ -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",

View File

@ -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;");
}

View File

@ -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=<code>&answers=1`
Allowed roles: `coach`, `supervisor`, `admin` (coach must be assigned to the client).
Headers:
```http
Authorization: Bearer <token>
```
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`

View File

@ -5,6 +5,7 @@
* GET ?id=<questionnaireID> -> 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);

View File

@ -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,

257
lib/app_answers.php Normal file
View File

@ -0,0 +1,257 @@
<?php
/**
* Mobile app answer ingest/export helpers (submit payload ↔ client_answer rows).
*/
/** Encode multi-select keys the same way the Android app stores them locally. */
function qdb_encode_multi_check_values(array $keys): string {
$clean = [];
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k !== '') {
$clean[$k] = true;
}
}
return json_encode(array_keys($clean), JSON_UNESCAPED_UNICODE);
}
/** @return list<string> */
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<string, string> $shortIdToType question localId => layout type
* @return list<array<string, mixed>>
*/
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<array<string, mixed>>, 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<array<string, mixed>>
*/
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;
}

View File

@ -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 (

View File

@ -279,7 +279,8 @@ function configFormHTML(layout, config, prefix) {
<div class="form-group">
<label>Min selections</label>
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
</div>`;
</div>
${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() {
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
</label>
</div>
<div class="form-group">
<label>Category key (app grouping)</label>
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
</div>
</div>
${editable ? `
<details class="condition-details" style="margin-bottom:16px">
@ -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');
}