prepare(" SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson FROM question WHERE questionnaireID = :qid ORDER BY orderIndex "); $stmt->execute([':qid' => $qnID]); $questions = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($questions as &$q) { $q['isRequired'] = (int)$q['isRequired']; $q['orderIndex'] = (int)$q['orderIndex']; $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 "); $ao->execute([':qid' => $q['questionID']]); $q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC); 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); } unset($q); qdb_discard($tmpDb, $lockFp); json_success(['questions' => $questions]); } catch (Throwable $e) { if (isset($tmpDb, $lockFp)) { qdb_discard($tmpDb, $lockFp); } if (function_exists('qdb_api_log_note_exception')) { qdb_api_log_note_exception($e); } require_once __DIR__ . '/../lib/errors.php'; error_log('questions GET: ' . $e->getMessage()); json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load questions'), 500); } break; case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); 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); $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"); $chk->execute([':id' => $qnID]); if (!$chk->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Questionnaire not found', 404); } if ($order === 0) { $max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM question WHERE questionnaireID = :id"); $max->execute([':id' => $qnID]); $order = (int)$max->fetchColumn(); } $localId = trim($body['localId'] ?? ''); if ($localId === '') { $localId = qdb_allocate_question_local_id($pdo, $qnID); } $id = qdb_make_question_id($qnID, $localId); $dup = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id'); $dup->execute([':id' => $id]); if ($dup->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('CONFLICT', "Question id \"$localId\" already exists in this questionnaire", 409); } $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' => $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' => $configJson, 'answerOptions' => [], 'translations' => [], ]]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Server error', 500); } break; case 'PUT': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); if (empty($body['questionID'])) { json_error('BAD_REQUEST', 'questionID is required', 400); } $id = $body['questionID']; [$pdo, $tmpDb, $lockFp] = qdb_open(true); try { $existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id"); $existing->execute([':id' => $id]); $row = $existing->fetch(PDO::FETCH_ASSOC); if (!$row) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Question not found', 404); } $text = trim($body['defaultText'] ?? $row['defaultText']); qdb_require_non_empty_german($text); $type = trim($body['type'] ?? $row['type']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $req = (int)($body['isRequired'] ?? $row['isRequired']); $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' => $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, 'questionKey' => $questionKey, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'configJson' => $configJson, ]]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Server error', 500); } break; case 'DELETE': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); if (empty($body['questionID'])) { json_error('BAD_REQUEST', 'questionID is required', 400); } $id = $body['questionID']; [$pdo, $tmpDb, $lockFp] = qdb_open(true); try { $pdo->exec("PRAGMA foreign_keys = OFF;"); $pdo->beginTransaction(); $aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id"); $aoIds->execute([':id' => $id]); foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) { $pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]); } $pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]); $pdo->commit(); $pdo->exec("PRAGMA foreign_keys = ON;"); qdb_save($tmpDb, $lockFp); json_success(['deleted' => true]); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Server error', 500); } break; case 'PATCH': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) { json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400); } $qnID = (string)$body['questionnaireID']; $orderIds = array_values(array_filter( array_map('strval', $body['order']), static fn($id) => $id !== '' )); if ($orderIds === []) { json_error('BAD_REQUEST', 'order[] must list at least one questionID', 400); } try { $opened = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; $chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id'); $chk->execute([':id' => $qnID]); if (!$chk->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Questionnaire not found', 404); } $ph = implode(',', array_fill(0, count($orderIds), '?')); $stmt = $pdo->prepare( "SELECT questionID FROM question WHERE questionnaireID = ? AND questionID IN ($ph)" ); $stmt->execute(array_merge([$qnID], $orderIds)); $found = $stmt->fetchAll(PDO::FETCH_COLUMN); if (count($found) !== count($orderIds)) { $missing = array_values(array_diff($orderIds, $found)); qdb_discard($tmpDb, $lockFp); json_error( 'INVALID_FIELD', 'order[] contains questionIDs not in this questionnaire', 400, ['missingQuestionIDs' => $missing] ); } $upd = $pdo->prepare( 'UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid' ); foreach ($orderIds as $idx => $qid) { $upd->execute([':o' => $idx, ':id' => $qid, ':qid' => $qnID]); } qdb_save($tmpDb, $lockFp); json_success(['reordered' => true]); } catch (Throwable $e) { if (isset($tmpDb, $lockFp)) { qdb_discard($tmpDb, $lockFp); } if (function_exists('qdb_api_log_note_exception')) { qdb_api_log_note_exception($e); } require_once __DIR__ . '/../lib/errors.php'; error_log('questions PATCH: ' . $e->getMessage()); json_error('SERVER_ERROR', qdb_public_error_message($e, 'Reorder questions'), 500); } break; default: json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); }