Files
barometer-server/handlers/app_questionnaires.php
Tom Hempel 70dca17451
Some checks failed
PHPUnit / test (push) Has been cancelled
removing last page from being stored
2026-07-01 09:33:44 +02:00

674 lines
27 KiB
PHP

<?php
/**
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* 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)
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
* GET ?sessions=1 -> active program session definitions with questionnaire assignments
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (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).
*/
// Public app UI catalog for the login screen (no token, no PII).
if ($method === 'GET' && !empty($_GET['translations'])) {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
qdb_discard($tmpDb, $lockFp);
return;
}
$tokenRec = require_valid_token();
if ($method === 'POST') {
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
$body = read_encrypted_json_body($tokenRec['_token']);
if (($body['action'] ?? '') === 'coachScoringReview') {
require_fields($body, ['clientCode', 'profileID', 'coachBand']);
$clientCode = trim((string)$body['clientCode']);
$profileID = trim((string)$body['profileID']);
$coachBand = trim((string)($body['coachBand'] ?? ''));
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$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/scoring.php';
if (!qdb_is_valid_scoring_band($coachBand)) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
}
$profile = qdb_save_coach_scoring_review(
$pdo,
$clientCode,
$profileID,
$coachBand,
(string)($tokenRec['userID'] ?? ''),
$weightedTotal,
$calculatedBand !== '' ? $calculatedBand : null,
);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => $profile]);
} catch (InvalidArgumentException $e) {
qdb_discard($tmpDb ?? null, $lockFp ?? null);
json_error('INVALID_FIELD', $e->getMessage(), 400);
} catch (RuntimeException $e) {
qdb_discard($tmpDb ?? null, $lockFp ?? null);
json_error('NOT_FOUND', $e->getMessage(), 404);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Coach scoring review', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
}
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']);
$clientCode = trim($body['clientCode']);
$answers = $body['answers'];
if (!is_array($answers)) {
json_error('INVALID_FIELD', 'answers must be an array', 400);
}
$startedAt = isset($body['startedAt']) ? (int)$body['startedAt'] : null;
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
$programCycleNumber = isset($body['programCycleNumber']) ? (int)$body['programCycleNumber'] : null;
$programSessionID = isset($body['programSessionID'])
? trim((string)$body['programSessionID'])
: null;
if ($programSessionID === '') {
$programSessionID = null;
}
$programSessionNumber = isset($body['programSessionNumber'])
? (int)$body['programSessionNumber']
: null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qnStmt = $pdo->prepare(
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire WHERE questionnaireID = :id'
);
$qnStmt->execute([':id' => $qnID]);
$qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$currentRev = max(1, (int)$qnRow['structureRevision']);
$submitRev = max(1, (int)($body['structureRevision'] ?? 1));
if ($submitRev > $currentRev) {
qdb_discard($tmpDb, $lockFp);
json_error('STRUCTURE_REVISION_NEWER', 'App structure revision is newer than server', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
$clientRow = $clStmt->fetch(PDO::FETCH_ASSOC);
if (!$clientRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$assignedByCoach = ($tokenRec['role'] === 'coach')
? ($tokenRec['entityID'] ?? '')
: ($clientRow['coachID'] ?? '');
$isLegacySubmit = $submitRev < $currentRev;
if ($isLegacySubmit) {
$submitManifest = qdb_load_structure_manifest($pdo, $qnID, $submitRev);
if ($submitManifest === null) {
qdb_discard($tmpDb, $lockFp);
json_error('STRUCTURE_REVISION_UNKNOWN', 'Unknown structure revision', 400);
}
} else {
$submitManifest = qdb_build_structure_manifest($pdo, $qnID, false);
$submitManifest['structureRevision'] = $currentRev;
}
$maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest);
$shortIdMap = $maps['shortIdMap'];
$shortIdToType = $maps['shortIdToType'];
$optionMap = $maps['optionMap'];
$symptomParentMap = $maps['symptomParentMap'];
$freeTextMaxLen = $maps['freeTextMaxLen'];
require_once __DIR__ . '/../lib/app_submit_validate.php';
$validationErrors = qdb_validate_app_submit_payload(
$pdo,
$qnID,
$answers,
$shortIdMap,
$shortIdToType,
$symptomParentMap,
$optionMap,
$submitManifest
);
if ($validationErrors !== []) {
qdb_discard($tmpDb, $lockFp);
json_error(
'VALIDATION_FAILED',
'Some answers are invalid or missing',
400,
['errors' => $validationErrors]
);
}
$pdo->beginTransaction();
$glassByParent = [];
$submittedQuestionIds = [];
$answerInsert = $pdo->prepare("
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
ON CONFLICT(clientCode, questionID) DO UPDATE SET
answerOptionID = excluded.answerOptionID,
freeTextValue = excluded.freeTextValue,
numericValue = excluded.numericValue,
answeredAt = excluded.answeredAt
");
$answerDelete = $pdo->prepare(
'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
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;
}
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
// Glass-scale symptoms use string keys (not question localIds).
if (isset($symptomParentMap[$shortId])) {
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
if ($label !== '') {
$parentId = $symptomParentMap[$shortId];
$glassByParent[$parentId][$shortId] = $label;
}
continue;
}
// Resolve short ID to full questionID
$fullQID = $shortIdMap[$shortId] ?? null;
if ($fullQID === null) {
continue;
}
$qType = $shortIdToType[$shortId] ?? '';
if (qdb_is_non_stored_answer_question_type($qType)) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
continue;
}
$answerOptionID = null;
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
if ($freeTextValue !== null && $freeTextValue !== '') {
$maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
$freeTextValue = sanitize_free_text($freeTextValue, $maxLen);
if ($freeTextValue === '') {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
}
// Resolve option key to answerOptionID (points computed by scoring engine after save)
$optionKey = $a['answerOptionKey'] ?? null;
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
$opt = $optionMap[$fullQID][(string)$optionKey];
$answerOptionID = $opt['answerOptionID'];
}
$hasValue = $answerOptionID !== null
|| ($freeTextValue !== null && $freeTextValue !== '')
|| $numericValue !== null;
if (!$hasValue) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
$answerInsert->execute([
':cc' => $clientCode,
':qid' => $fullQID,
':aoid' => $answerOptionID,
':ftv' => $freeTextValue,
':nv' => $numericValue,
':at' => $answeredAt,
]);
$submittedQuestionIds[$fullQID] = true;
}
foreach ($glassByParent as $parentQID => $symptomLabels) {
$json = qdb_build_glass_symptom_json($symptomLabels);
if ($json === null) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $parentQID]);
} else {
$answerInsert->execute([
':cc' => $clientCode,
':qid' => $parentQID,
':aoid' => null,
':ftv' => $json,
':nv' => null,
':at' => $completedAt ?? $startedAt,
]);
}
$submittedQuestionIds[$parentQID] = true;
}
if (!$isLegacySubmit) {
$activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID);
$staleQuestionIds = array_diff(
array_values($activeMaps['shortIdMap']),
array_keys($submittedQuestionIds)
);
if ($staleQuestionIds !== []) {
$placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?'));
$staleDelete = $pdo->prepare(
"DELETE FROM client_answer WHERE clientCode = ? AND questionID IN ($placeholders)"
);
$staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds)));
}
}
require_once __DIR__ . '/../lib/scoring.php';
$sumPoints = $isLegacySubmit
? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest)
: qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
$pdo->prepare("
INSERT INTO completed_questionnaire (
clientCode, questionnaireID, assignedByCoach, status,
startedAt, completedAt, sumPoints,
programCycleNumber, programSessionID, programSessionNumber
)
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp, :pcn, :psid, :psn)
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET
assignedByCoach = excluded.assignedByCoach,
status = 'completed',
startedAt = COALESCE(excluded.startedAt, startedAt),
completedAt = excluded.completedAt,
sumPoints = excluded.sumPoints,
programCycleNumber = COALESCE(excluded.programCycleNumber, programCycleNumber),
programSessionID = COALESCE(excluded.programSessionID, programSessionID),
programSessionNumber = COALESCE(excluded.programSessionNumber, programSessionNumber)
")->execute([
':cc' => $clientCode,
':qn' => $qnID,
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
':sa' => $startedAt,
':ca' => $completedAt,
':sp' => $sumPoints,
':pcn' => $programCycleNumber,
':psid'=> $programSessionID,
':psn' => $programSessionNumber,
]);
require_once __DIR__ . '/../lib/submissions.php';
qdb_record_submission_after_submit(
$pdo,
$clientCode,
$qnID,
$tokenRec,
$startedAt,
$completedAt,
$sumPoints,
$assignedByCoach,
$submitRev,
$submitManifest,
array_keys($submittedQuestionIds),
null,
$programCycleNumber,
$programSessionID,
$programSessionNumber
);
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
require_once __DIR__ . '/../lib/program_sessions.php';
$cycleAdvanced = qdb_maybe_advance_program_cycle($pdo, $clientCode);
$cycle = qdb_client_program_cycle_get($pdo, $clientCode);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([
'submitted' => true,
'sumPoints' => $sumPoints,
'structureRevision' => $submitRev,
'legacySubmit' => $isLegacySubmit,
'cycleAdvanced' => $cycleAdvanced,
'cycleNumber' => $cycle['cycleNumber'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
}
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? '';
$fetchAnswers = $_GET['answers'] ?? '';
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
$fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
$fetchProgramSessions = $_GET['sessions'] ?? '';
$fetchScoringReview = $_GET['scoringReview'] ?? '';
$clientCode = trim($_GET['clientCode'] ?? '');
if ($fetchProgramSessions) {
require_once __DIR__ . '/../lib/program_sessions.php';
json_success(qdb_app_program_sessions_payload($pdo));
qdb_discard($tmpDb, $lockFp);
return;
}
if ($fetchScoringProfiles) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$profiles = array_values(array_filter(
qdb_list_scoring_profiles($pdo),
static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1
));
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']);
}
if ($fetchScoringReview) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$coachID = $tokenRec['entityID'] ?? '';
if ($clientCode !== '') {
[$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);
}
$clientCodes = [$clientCode];
} else {
$stmt = $pdo->prepare(
'SELECT clientCode FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode'
);
$stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
$clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
if ($fetchAnswersBulk) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/app_answers.php';
$clients = qdb_export_app_bulk_answers_for_coach($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
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);
$coachID = $tokenRec['entityID'] ?? '';
$stmt = $pdo->prepare("
SELECT clientCode
FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode
");
$stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
$completions = [];
if (!empty($clientCodes)) {
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
$stmt2 = $pdo->prepare("
SELECT clientCode, questionnaireID, sumPoints, completedAt
FROM completed_questionnaire
WHERE clientCode IN ($placeholders)
");
$stmt2->execute($clientCodes);
foreach ($stmt2->fetchAll(PDO::FETCH_ASSOC) as $row) {
$completions[$row['clientCode']][] = [
'questionnaireID' => $row['questionnaireID'],
'sumPoints' => (int) $row['sumPoints'],
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
];
}
}
require_once __DIR__ . '/../lib/program_sessions.php';
$clients = array_map(function ($code) use ($completions, $pdo) {
return [
'clientCode' => $code,
'completedQuestionnaires' => $completions[$code] ?? [],
'programProgress' => qdb_client_program_progress($pdo, $code),
];
}, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
}
if ($qnID) {
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
$stmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson
FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
ORDER BY orderIndex'
);
$stmt->execute([':id' => $qnID]);
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questions = [];
foreach ($dbQuestions as $dbQ) {
$localId = $dbQ['questionID'];
$parts = explode('__', $localId);
$shortId = end($parts);
$layout = $dbQ['type'];
$config = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$q = [
'id' => $shortId,
'layout' => $layout,
'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'];
if (isset($config['hint'])) $q['hint'] = $config['hint'];
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if ($layout === 'glass_scale_question' && !empty($config['symptoms'])) {
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $config);
}
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 (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' || $layout === 'slider_question') && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions'];
}
if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) {
require_once __DIR__ . '/../lib/scoring.php';
$ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']);
if ($ruleMap !== []) {
$q['scoreRuleMap'] = $ruleMap;
}
}
$ao = $pdo->prepare(
'SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
ORDER BY orderIndex'
);
$ao->execute([':qid' => $dbQ['questionID']]);
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
if ($opts) {
$optionsArr = [];
$pointsMap = [];
foreach ($opts as $opt) {
$o = ['key' => $opt['defaultText']];
if ($opt['nextQuestionId'] !== '') {
$o['nextQuestionId'] = $opt['nextQuestionId'];
}
$optionsArr[] = $o;
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
}
// DB answer_option rows are for choice layouts only — do not overwrite string/value spinners.
if (in_array($layout, ['radio_question', 'multi_check_box_question', 'glass_scale_question'], true)) {
$q['options'] = $optionsArr;
$q['pointsMap'] = $pointsMap;
}
}
$questions[] = $q;
}
qdb_discard($tmpDb, $lockFp);
json_success([
'meta' => ['id' => $qnID],
'structureRevision' => $structureRevision,
'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]);
}
// Default: ordered questionnaire list
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$list = [];
foreach ($rows as $r) {
$item = [
'id' => $r['questionnaireID'],
'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'],
'structureRevision' => max(1, (int)$r['structureRevision']),
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
];
$cat = trim($r['categoryKey'] ?? '');
if ($cat !== '') {
$item['categoryKey'] = $cat;
}
$list[] = $item;
}
qdb_discard($tmpDb, $lockFp);
json_success($list);