enhanced questionnaire handling with stable key validation, improved note management, and added option key support

This commit is contained in:
2026-05-26 15:40:35 +02:00
parent 1b3d74d822
commit 7671c9f329
10 changed files with 876 additions and 100 deletions

View File

@ -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 : []);
}
}