diff --git a/api/app_questionnaires.php b/api/app_questionnaires.php index 0813d4b..db60c8e 100644 --- a/api/app_questionnaires.php +++ b/api/app_questionnaires.php @@ -58,15 +58,21 @@ if ($qnID) { $shortId = end($parts); $layout = $dbQ['type']; - $config = json_decode($dbQ['configJson'], true) ?: []; + $config = qdb_parse_config_json($dbQ['configJson']); + $qKey = qdb_question_key($config, $dbQ['defaultText']); $q = [ 'id' => $shortId, 'layout' => $layout, - 'question' => $dbQ['defaultText'], + 'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'], ]; - // Add type-specific config fields back to the top level + if ($qKey !== '' && !empty($config['noteBefore'])) { + $q['noteBeforeKey'] = qdb_note_before_key($qKey); + } + if ($qKey !== '' && !empty($config['noteAfter'])) { + $q['noteAfterKey'] = qdb_note_after_key($qKey); + } if (isset($config['textKey'])) $q['textKey'] = $config['textKey']; if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1']; if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2']; @@ -76,21 +82,20 @@ if ($qnID) { if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms']; if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType']; if (isset($config['range'])) $q['range'] = $config['range']; + if (isset($config['step'])) $q['step'] = (int)$config['step']; + if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['precision'])) $q['precision'] = $config['precision']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; - if (!empty($config['multiline'])) $q['multiline'] = true; + if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId']; if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; - // String spinner static options if ($layout === 'string_spinner' && isset($config['options'])) { $q['options'] = $config['options']; } - - // Value spinner conditional navigation options - if ($layout === 'value_spinner' && isset($config['valueOptions'])) { + if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) { $q['options'] = $config['valueOptions']; } diff --git a/common.php b/common.php index 9b05818..b48df17 100644 --- a/common.php +++ b/common.php @@ -305,10 +305,19 @@ function qdb_all_questionnaire_translation_key_set(PDO $pdo): array { $keys = []; $rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $dbQ) { - if ($dbQ['defaultText'] !== '') { + $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; } - $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { if (!empty($config[$field])) { $keys[$config[$field]] = true; @@ -373,10 +382,7 @@ function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, s 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]); - } + // 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) @@ -401,6 +407,135 @@ function qdb_require_non_empty_german(string $text, string $label = 'German text } } +/** 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); +} + +function qdb_validate_stable_key(string $s, string $label = 'Key'): string { + $s = trim($s); + if (!qdb_is_stable_key($s)) { + json_error('INVALID_FIELD', "$label must match [a-zA-Z][a-zA-Z0-9_]*", 400); + } + return $s; +} + +/** 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 : ''; +} + +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; +} + +/** 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 ''; +} + /** * Build translation entry lists for one questionnaire. * Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved). @@ -420,9 +555,16 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { 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' => $dbQ['defaultText'], - 'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal, + '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, @@ -434,20 +576,23 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { "); $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)); + $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' => $ao['defaultText'], + '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), ]; } - $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; - foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { + foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) { if (!empty($config[$field])) { $stringKeys[$config[$field]] = true; } @@ -457,6 +602,14 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { $stringKeys[$s] = true; } } + if ($qKey !== '') { + if (!empty($config['noteBefore'])) { + $stringKeys[qdb_note_before_key($qKey)] = true; + } + if (!empty($config['noteAfter'])) { + $stringKeys[qdb_note_after_key($qKey)] = true; + } + } } $appKeySet = qdb_app_string_key_set(); @@ -689,18 +842,27 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { FROM answer_option WHERE questionID = :qid 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[] = [ - 'defaultText' => $ao['defaultText'], + 'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'], + 'defaultText' => $optGerman, 'points' => (int)$ao['points'], 'orderIndex' => (int)$ao['orderIndex'], 'nextQuestionId' => $ao['nextQuestionId'] ?? '', - 'translations' => qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']), + 'translations' => $optTr, ]; } $questionsOut[] = [ 'localId' => $localId, + 'questionKey' => $qKey, 'defaultText' => $dbQ['defaultText'], 'type' => $dbQ['type'], 'orderIndex' => (int)$dbQ['orderIndex'], @@ -839,11 +1001,33 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE $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); + $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)") @@ -857,32 +1041,50 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE ':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 : []); + qdb_sync_question_note_strings($pdo, $questionKey, $config); foreach ($q['answerOptions'] ?? [] as $opt) { - $optText = trim($opt['defaultText'] ?? ''); - qdb_require_non_empty_german($optText, 'Answer option German text'); + $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' => $optText, + ':t' => $optKey, ':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_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman); qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []); } } diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 2ef79a6..ff11b53 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -57,6 +57,8 @@ "november", "october", "offline", + "other_country", + "other_option", "ok", "online", "password_hint", @@ -138,6 +140,8 @@ "november": "November", "october": "Oktober", "offline": "Offline", + "other_country": "anderes Land", + "other_option": "Sonstiges", "ok": "OK", "online": "Online", "password_hint": "Passwort", diff --git a/data/countries_de_sorted.txt b/data/countries_de_sorted.txt new file mode 100644 index 0000000..9c78521 --- /dev/null +++ b/data/countries_de_sorted.txt @@ -0,0 +1,190 @@ +Ägypten +Äquatorialguinea +Äthiopien +Afghanistan +Albanien +Algerien +Andorra +Angola +Antigua und Barbuda +Argentinien +Armenien +Aserbaidschan +Australien +Bahamas +Bahrain +Bangladesch +Barbados +Belgien +Belize +Benin +Bhutan +Bolivien +Bosnien und Herzegowina +Botswana +Brasilien +Brunei +Bulgarien +Burkina Faso +Burundi +Cabo Verde +Cambodia +Chile +China +Costa Rica +Dänemark +Deutschland +Djibouti +Dominica +Dominikanische Republik +Ecuador +El Salvador +Eritrea +Estland +Eswatini +Fiji +Finnland +Frankreich +Gabon +Gambia +Georgien +Ghana +Grenada +Griechenland +Guatemala +Guinea +Guinea-Bissau +Guyana +Haiti +Honduras +Indien +Indonesien +Irak +Iran +Irland +Island +Israel +Italien +Jamaika +Japan +Jordanien +Kamerun +Kanada +Kasachstan +Kenia +Kiribati +Kolumbien +Komoren +Kongo (Dem. Rep.) +Kongo (Rep.) +Korea (Nord) +Korea (Süd) +Kroatien +Kuba +Kuwait +Kyrgyzstan +Laos +Latvia +Lebanon +Lesotho +Liberia +Libyen +Liechtenstein +Litauen +Luxemburg +Madagaskar +Malawi +Malaysia +Maldiven +Mali +Malta +Marokko +Marshallinseln +Mauritanien +Mauritius +Mexiko +Mikronesien +Moldawien +Monaco +Mongolei +Montenegro +Mozambique +Namibia +Nauru +Nepal +Nicaragua +Niger +Nigeria +Nordmazedonien +Norwegen +Österreich +Oman +Pakistan +Palau +Panama +Papua-Neuguinea +Paraguay +Peru +Philippinen +Polen +Portugal +Ruanda +Rumänien +Russland +Salomonen +Sambia +Samoa +San Marino +Sao Tome und Principe +Saudi-Arabien +Schweden +Schweiz +Senegal +Serbien +Seychellen +Sierra Leone +Simbabwe +Singapur +Slowakei +Slowenien +Spanien +Sri Lanka +St. Kitts und Nevis +St. Lucia +St. Vincent und die Grenadinen +Sudan +Südafrika +Südkorea +Südsudan +Suriname +Syrien +São Tomé und Príncipe +Tadschikistan +Taiwan +Tansania +Togo +Tonga +Trinidad und Tobago +Tschad +Tschechien +Türkei +Tunisien +Turkmenistan +Tuvalu +Uganda +Ukraine +Ungarn +Uruguay +Usbekistan +Vanuatu +Venezuela +Vereinigte Arabische Emirate +Vereinigte Staaten +Vereinigtes Königreich +Vietnam +Wallis und Futuna +Westjordanland +Westsahara +Yemen +Zentralafrikanische Republik +Zypern diff --git a/handlers/answer_options.php b/handlers/answer_options.php index 3442573..433f327 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -17,6 +17,8 @@ case 'GET': foreach ($options as &$o) { $o['points'] = (int)$o['points']; $o['orderIndex'] = (int)$o['orderIndex']; + $o['optionKey'] = qdb_option_key($o); + $o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']); $tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); $tr->execute([':id' => $o['answerOptionID']]); $o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); @@ -29,13 +31,14 @@ case 'GET': case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); - if (empty($body['questionID']) || !isset($body['defaultText'])) { - json_error('MISSING_FIELDS', 'questionID and defaultText required', 400); + if (empty($body['questionID']) || empty($body['optionKey']) || !isset($body['defaultText'])) { + json_error('MISSING_FIELDS', 'questionID, optionKey, and defaultText (German label) required', 400); } $id = bin2hex(random_bytes(16)); $qID = $body['questionID']; + $optKey = qdb_validate_stable_key((string)$body['optionKey'], 'Option key'); $text = trim($body['defaultText']); - qdb_require_non_empty_german($text); + qdb_require_non_empty_german($text, 'German label'); $points = (int)($body['points'] ?? 0); $order = (int)($body['orderIndex'] ?? 0); $nextQ = trim($body['nextQuestionId'] ?? ''); @@ -53,14 +56,17 @@ case 'POST': $max->execute([':id' => $qID]); $order = (int)$max->fetchColumn(); } + qdb_assert_unique_option_key($pdo, $qID, $optKey); $pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)') - ->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]); + ->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]); qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); qdb_save($tmpDb, $lockFp); json_success(['answerOption' => [ 'answerOptionID' => $id, 'questionID' => $qID, - 'defaultText' => $text, + 'optionKey' => $optKey, + 'defaultText' => $optKey, + 'labelGerman' => $text, 'points' => $points, 'orderIndex' => $order, 'nextQuestionId' => $nextQ, @@ -90,20 +96,32 @@ case 'PUT': qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Answer option not found', 404); } - $text = trim($body['defaultText'] ?? $row['defaultText']); - qdb_require_non_empty_german($text); + $optKey = isset($body['optionKey']) + ? qdb_validate_stable_key((string)$body['optionKey'], 'Option key') + : qdb_option_key($row); + if ($optKey === '') { + json_error('MISSING_FIELDS', 'optionKey is required', 400); + } + $labelGerman = trim($body['defaultText'] ?? $body['labelGerman'] ?? ''); + if ($labelGerman === '') { + $labelGerman = qdb_option_german_label($pdo, $id, $row['defaultText']); + } + qdb_require_non_empty_german($labelGerman, 'German label'); + qdb_assert_unique_option_key($pdo, $row['questionID'], $optKey, $id); $points = (int)($body['points'] ?? $row['points']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']); $pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id') - ->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]); - qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); + ->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]); + qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman); qdb_save($tmpDb, $lockFp); json_success(['answerOption' => [ 'answerOptionID' => $id, 'questionID' => $row['questionID'], - 'defaultText' => $text, + 'optionKey' => $optKey, + 'defaultText' => $optKey, + 'labelGerman' => $labelGerman, 'points' => $points, 'orderIndex' => $order, 'nextQuestionId' => $nextQ, diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 1be6431..cc653bc 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -250,14 +250,21 @@ if ($qnID) { $shortId = end($parts); $layout = $dbQ['type']; - $config = json_decode($dbQ['configJson'], true) ?: []; + $config = qdb_parse_config_json($dbQ['configJson']); + $qKey = qdb_question_key($config, $dbQ['defaultText']); $q = [ 'id' => $shortId, 'layout' => $layout, - 'question' => $dbQ['defaultText'], + 'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'], ]; + if ($qKey !== '' && !empty($config['noteBefore'])) { + $q['noteBeforeKey'] = qdb_note_before_key($qKey); + } + if ($qKey !== '' && !empty($config['noteAfter'])) { + $q['noteAfterKey'] = qdb_note_after_key($qKey); + } if (isset($config['textKey'])) $q['textKey'] = $config['textKey']; if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1']; if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2']; @@ -267,18 +274,20 @@ if ($qnID) { if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms']; if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType']; if (isset($config['range'])) $q['range'] = $config['range']; + if (isset($config['step'])) $q['step'] = (int)$config['step']; + if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['precision'])) $q['precision'] = $config['precision']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; - if (!empty($config['multiline'])) $q['multiline'] = true; + if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId']; if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; if ($layout === 'string_spinner' && isset($config['options'])) { $q['options'] = $config['options']; } - if ($layout === 'value_spinner' && isset($config['valueOptions'])) { + if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) { $q['options'] = $config['valueOptions']; } diff --git a/handlers/questions.php b/handlers/questions.php index 43d15d3..feb1ded 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -19,7 +19,10 @@ case 'GET': foreach ($questions as &$q) { $q['isRequired'] = (int)$q['isRequired']; $q['orderIndex'] = (int)$q['orderIndex']; - $q['configJson'] = $q['configJson'] ?: '{}'; + $cfg = qdb_parse_config_json($q['configJson']); + $q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE); + $q['questionKey'] = qdb_question_key($cfg, $q['defaultText']); + $q['localId'] = qdb_question_local_id($q['questionID'], $qnID); $ao = $pdo->prepare(" SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex @@ -29,7 +32,10 @@ case 'GET': foreach ($q['answerOptions'] as &$opt) { $opt['points'] = (int)$opt['points']; $opt['orderIndex'] = (int)$opt['orderIndex']; + $opt['optionKey'] = qdb_option_key($opt); + $opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']); } + unset($opt); $tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid"); $tr->execute([':qid' => $q['questionID']]); $q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); @@ -42,16 +48,24 @@ case 'GET': case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); - if (empty($body['questionnaireID']) || !isset($body['defaultText'])) { - json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400); + if (empty($body['questionnaireID']) || !isset($body['defaultText']) || empty($body['questionKey'])) { + json_error('BAD_REQUEST', 'questionnaireID, questionKey, and defaultText required', 400); } $qnID = $body['questionnaireID']; $text = trim($body['defaultText']); qdb_require_non_empty_german($text); + $questionKey = qdb_validate_stable_key((string)$body['questionKey'], 'Question key'); $type = trim($body['type'] ?? ''); $order = (int)($body['orderIndex'] ?? 0); $req = (int)($body['isRequired'] ?? 0); - $config = $body['configJson'] ?? '{}'; + $cfg = qdb_parse_config_json($body['configJson'] ?? '{}'); + $config = qdb_normalize_question_config( + $cfg, + $questionKey, + $body['noteBefore'] ?? ($cfg['noteBefore'] ?? null), + $body['noteAfter'] ?? ($cfg['noteAfter'] ?? null) + ); + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); [$pdo, $tmpDb, $lockFp] = qdb_open(true); try { $chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id"); @@ -79,13 +93,15 @@ case 'POST': $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") ->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type, - ':o' => $order, ':r' => $req, ':cj' => $config]); + ':o' => $order, ':r' => $req, ':cj' => $configJson]); qdb_upsert_source_translation($pdo, 'question', $id, $text); + qdb_sync_question_note_strings($pdo, $questionKey, $config); qdb_save($tmpDb, $lockFp); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, + 'questionKey' => $questionKey, 'localId' => $localId, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, - 'configJson' => $config, 'answerOptions' => [], 'translations' => [], + 'configJson' => $configJson, 'answerOptions' => [], 'translations' => [], ]]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); @@ -115,15 +131,31 @@ case 'PUT': $type = trim($body['type'] ?? $row['type']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $req = (int)($body['isRequired'] ?? $row['isRequired']); - $config = $body['configJson'] ?? $row['configJson']; + $oldCfg = qdb_parse_config_json($row['configJson']); + $questionKey = isset($body['questionKey']) + ? qdb_validate_stable_key((string)$body['questionKey'], 'Question key') + : qdb_question_key($oldCfg, $row['defaultText']); + if ($questionKey === '') { + json_error('MISSING_FIELDS', 'questionKey is required', 400); + } + $cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg; + $config = qdb_normalize_question_config( + $cfgIn, + $questionKey, + $body['noteBefore'] ?? ($cfgIn['noteBefore'] ?? null), + $body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null) + ); + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); $pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id") - ->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]); + ->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]); qdb_upsert_source_translation($pdo, 'question', $id, $text); + qdb_sync_question_note_strings($pdo, $questionKey, $config); qdb_save($tmpDb, $lockFp); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], - 'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, - 'configJson' => $config, + 'defaultText' => $text, 'questionKey' => $questionKey, + 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, + 'configJson' => $configJson, ]]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); diff --git a/import_questionnaires.php b/import_questionnaires.php index 55db515..746c6fa 100644 --- a/import_questionnaires.php +++ b/import_questionnaires.php @@ -89,11 +89,11 @@ try { if (isset($q['precision'])) $config['precision'] = $q['precision']; if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection']; if (isset($q['maxLength'])) $config['maxLength'] = (int)$q['maxLength']; - if (!empty($q['multiline'])) $config['multiline'] = true; + if (isset($q['nextQuestionId'])) $config['nextQuestionId'] = $q['nextQuestionId']; if (isset($q['otherNextQuestionId'])) $config['otherNextQuestionId'] = $q['otherNextQuestionId']; if (isset($q['otherOptionKey'])) $config['otherOptionKey'] = $q['otherOptionKey']; if (isset($q['config']) && is_array($q['config'])) { - foreach (['otherNextQuestionId', 'otherOptionKey', 'hint', 'maxLength', 'multiline', 'textKey', 'precision', 'scaleType'] as $ck) { + foreach (['nextQuestionId', 'otherNextQuestionId', 'otherOptionKey', 'hint', 'maxLength', 'textKey', 'precision', 'scaleType', 'noteBefore', 'noteAfter', 'step', 'unitLabel'] as $ck) { if (isset($q['config'][$ck])) { $config[$ck] = $q['config'][$ck]; } @@ -117,7 +117,11 @@ try { } } + if ($qKey !== '') { + $config = qdb_normalize_question_config($config, $qKey); + } $configJson = !empty($config) ? json_encode($config) : '{}'; + $germanText = $qKey; $pdo->prepare("INSERT OR REPLACE INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) @@ -125,12 +129,16 @@ try { ->execute([ ':id' => $questionID, ':qnid' => $questionnaireID, - ':dt' => $qKey, + ':dt' => $germanText, ':ty' => $layout, ':oi' => $qIdx, ':ir' => 0, ':cj' => $configJson, ]); + if ($qKey !== '') { + qdb_upsert_source_translation($pdo, 'question', $questionID, $germanText); + qdb_sync_question_note_strings($pdo, $qKey, $config); + } if (isset($q['options']) && is_array($q['options'])) { $hasObjects = isset($q['options'][0]) && is_array($q['options'][0]); diff --git a/website/css/style.css b/website/css/style.css index 6b8d135..b6cd609 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1158,3 +1158,24 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } border-radius: 3px; transition: width .3s ease; } + +.field-hint { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 4px; +} +.field-hint code { + font-size: 0.75rem; +} + +.badge-warn { + background: #fef3c7; + color: #92400e; +} + +.opt-key { + font-size: 0.75rem; + margin-right: 8px; + color: var(--text-secondary); +} diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 07e3ec1..cdf9288 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -18,6 +18,7 @@ const LAYOUT_TYPES = [ { value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' }, { value: 'glass_scale_question', label: 'Glass Scale' }, { value: 'value_spinner', label: 'Value Spinner' }, + { value: 'slider_question', label: 'Slider (min–max)' }, { value: 'date_spinner', label: 'Date Spinner' }, { value: 'string_spinner', label: 'String Spinner' }, { value: 'free_text', label: 'Free Text' }, @@ -93,17 +94,174 @@ function layoutLabel(value) { return t ? t.label : value || 'unknown'; } +const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; + function parseConfig(q) { try { return JSON.parse(q.configJson || '{}'); } catch { return {}; } } +function questionKeyOf(q) { + const cfg = typeof q === 'object' && q.configJson !== undefined ? parseConfig(q) : (q.config || q); + return (q.questionKey || cfg.questionKey || '').trim(); +} + function questionLocalIds() { return questions.map(q => { const parts = q.questionID.split('__'); - return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText }; + const localId = parts[parts.length - 1]; + return { + questionID: q.questionID, + localId, + defaultText: q.defaultText, + questionKey: questionKeyOf(q), + }; }); } +function branchTargetLabel(q) { + const key = questionKeyOf(q) || q.localId; + return `${q.localId} · ${key}`; +} + +function branchTargetOptionsHTML(selected = '') { + return questionLocalIds().map(q => + `` + ).join(''); +} + +function validateStableKey(key, label = 'Key') { + if (!STABLE_KEY_RE.test(key)) { + showToast(`${label} must match [a-zA-Z][a-zA-Z0-9_]*`, 'error'); + return false; + } + return true; +} + +function optionKeyOf(o) { + if (o.optionKey) return o.optionKey; + const dt = (o.defaultText || '').trim(); + return STABLE_KEY_RE.test(dt) ? dt : ''; +} + +function optionGermanLabel(o) { + return (o.labelGerman || '').trim() || (STABLE_KEY_RE.test(o.defaultText || '') ? '' : o.defaultText) || ''; +} + +function notesSectionHTML(config, prefix, questionKey) { + const nbKey = questionKey ? `${questionKey}_note_before` : '—'; + const naKey = questionKey ? `${questionKey}_note_after` : '—'; + return ` +
${esc(nbKey)}
+ ${esc(naKey)}
+