enhanced questionnaire version handling and handling uploads from outdated questionnaires through automatic revisioning and checks
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-12 21:38:39 +02:00
parent 5dff24a232
commit b4d4de72f4
17 changed files with 1289 additions and 209 deletions

View File

@ -1744,6 +1744,7 @@ function qdb_translation_row_label(array $e): string {
/** Export one questionnaire with structure + all translations (portable JSON). */ /** Export one questionnaire with structure + all translations (portable JSON). */
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
require_once __DIR__ . '/lib/scoring.php'; require_once __DIR__ . '/lib/scoring.php';
require_once __DIR__ . '/lib/questionnaire_structure.php';
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id'); $qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$qn->execute([':id' => $qnID]); $qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC); $qnRow = $qn->fetch(PDO::FETCH_ASSOC);
@ -1751,10 +1752,11 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
return null; return null;
} }
$qStmt = $pdo->prepare(" $qStmt = $pdo->prepare(
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson 'SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :id ORDER BY orderIndex FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
"); ORDER BY orderIndex'
);
$qStmt->execute([':id' => $qnID]); $qStmt->execute([':id' => $qnID]);
$questionsOut = []; $questionsOut = [];
@ -1763,10 +1765,11 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
$optionsOut = []; $optionsOut = [];
$aoStmt = $pdo->prepare(" $aoStmt = $pdo->prepare(
SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId 'SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
"); ORDER BY orderIndex'
);
$aoStmt->execute([':qid' => $dbQ['questionID']]); $aoStmt->execute([':qid' => $dbQ['questionID']]);
$qKey = qdb_question_key($config, $dbQ['defaultText']); $qKey = qdb_question_key($config, $dbQ['defaultText']);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
@ -1821,6 +1824,7 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
'orderIndex' => (int)$qnRow['orderIndex'], 'orderIndex' => (int)$qnRow['orderIndex'],
'showPoints' => (int)$qnRow['showPoints'], 'showPoints' => (int)$qnRow['showPoints'],
'conditionJson' => $qnRow['conditionJson'] ?: '{}', 'conditionJson' => $qnRow['conditionJson'] ?: '{}',
'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID),
], ],
'questions' => $questionsOut, 'questions' => $questionsOut,
'stringTranslations' => $stringTranslations, 'stringTranslations' => $stringTranslations,
@ -1855,7 +1859,7 @@ function qdb_export_scoring_profiles_bundle(PDO $pdo): array {
return qdb_list_scoring_profiles($pdo); return qdb_list_scoring_profiles($pdo);
} }
/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */ /** Remove questionnaire and related rows (full replace import; not per-question retire). */
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void { function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
$qIds->execute([':id' => $qnID]); $qIds->execute([':id' => $qnID]);
@ -1870,8 +1874,20 @@ function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
} }
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]); $pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]); $pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
if (qdb_table_exists($pdo, 'client_answer_submission')) {
$pdo->prepare('DELETE FROM client_answer_submission WHERE questionID = :qid')
->execute([':qid' => $qid]);
}
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]); $pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
} }
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) {
$pdo->prepare('DELETE FROM questionnaire_structure_snapshot WHERE questionnaireID = :id')
->execute([':id' => $qnID]);
}
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->prepare('DELETE FROM questionnaire_submission WHERE questionnaireID = :id')
->execute([':id' => $qnID]);
}
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]); $pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]);
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); $pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); $pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);

View File

@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
} }
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 11); define('QDB_VERSION', 12);
function qdb_table_exists(PDO $pdo, string $table): bool { function qdb_table_exists(PDO $pdo, string $table): bool {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@ -55,6 +55,68 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
if (qdb_table_exists($pdo, 'questionnaire')) {
if (!qdb_column_exists($pdo, 'questionnaire', 'structureRevision')) {
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
$changed = true;
}
if (!qdb_column_exists($pdo, 'questionnaire', 'structureChangedAt')) {
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureChangedAt INTEGER NOT NULL DEFAULT 0');
$changed = true;
}
}
if (!qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) {
$pdo->exec("
CREATE TABLE questionnaire_structure_snapshot (
questionnaireID TEXT NOT NULL,
structureRevision INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
manifestJson TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (questionnaireID, structureRevision),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
)
");
$changed = true;
}
if (qdb_table_exists($pdo, 'question')) {
if (!qdb_column_exists($pdo, 'question', 'retiredAt')) {
$pdo->exec('ALTER TABLE question ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
$changed = true;
}
if (!qdb_column_exists($pdo, 'question', 'retiredInRevision')) {
$pdo->exec('ALTER TABLE question ADD COLUMN retiredInRevision INTEGER NOT NULL DEFAULT 0');
$changed = true;
}
}
if (qdb_table_exists($pdo, 'answer_option')) {
if (!qdb_column_exists($pdo, 'answer_option', 'retiredAt')) {
$pdo->exec('ALTER TABLE answer_option ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
$changed = true;
}
}
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureRevision')) {
$pdo->exec('ALTER TABLE questionnaire_submission ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
$changed = true;
}
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureSnapshotJson')) {
$pdo->exec("ALTER TABLE questionnaire_submission ADD COLUMN structureSnapshotJson TEXT NOT NULL DEFAULT '{}'");
$changed = true;
}
}
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')
&& qdb_table_exists($pdo, 'questionnaire')) {
require_once __DIR__ . '/lib/questionnaire_structure.php';
if (qdb_backfill_structure_snapshots($pdo)) {
$changed = true;
}
}
if (!qdb_table_exists($pdo, 'questionnaire_submission')) { if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->exec(" $pdo->exec("
CREATE TABLE questionnaire_submission ( CREATE TABLE questionnaire_submission (

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web(); $tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
@ -50,12 +52,14 @@ case 'POST':
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try { try {
$chk = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id'); $chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id');
$chk->execute([':id' => $qID]); $chk->execute([':id' => $qID]);
if (!$chk->fetch()) { $qnID = $chk->fetchColumn();
if (!$qnID) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404); json_error('NOT_FOUND', 'Question not found', 404);
} }
$qnID = (string)$qnID;
if ($order === 0) { if ($order === 0) {
$max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id'); $max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id');
$max->execute([':id' => $qID]); $max->execute([':id' => $qID]);
@ -65,8 +69,11 @@ case 'POST':
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)') $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' => $optKey, ':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_upsert_source_translation($pdo, 'answer_option', $id, $text);
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_added');
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['answerOption' => [ json_success([
'structureRevision' => $newRev,
'answerOption' => [
'answerOptionID' => $id, 'answerOptionID' => $id,
'questionID' => $qID, 'questionID' => $qID,
'optionKey' => $optKey, 'optionKey' => $optKey,
@ -115,11 +122,19 @@ case 'PUT':
$order = (int)($body['orderIndex'] ?? $row['orderIndex']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']); $nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
$keyChanged = $optKey !== qdb_option_key($row);
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id') $pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]); ->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman); qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman);
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$row['questionID']);
$newRev = null;
if ($keyChanged && $qnID !== null) {
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_key_changed');
}
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['answerOption' => [ json_success([
'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1),
'answerOption' => [
'answerOptionID' => $id, 'answerOptionID' => $id,
'questionID' => $row['questionID'], 'questionID' => $row['questionID'],
'optionKey' => $optKey, 'optionKey' => $optKey,
@ -144,15 +159,35 @@ case 'DELETE':
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try { try {
$pdo->exec('PRAGMA foreign_keys = OFF;'); $existing = $pdo->prepare('SELECT questionID FROM answer_option WHERE answerOptionID = :id');
$existing->execute([':id' => $id]);
$qid = $existing->fetchColumn();
if (!$qid) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$qid);
if ($qnID === null) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$pdo->beginTransaction(); $pdo->beginTransaction();
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]); $newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_removed');
$pdo->prepare('UPDATE client_answer SET answerOptionID = NULL WHERE answerOptionID = :id')->execute([':id' => $id]); if (qdb_option_has_client_data($pdo, $id)) {
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]); $pdo->prepare('UPDATE answer_option SET retiredAt = :ts WHERE answerOptionID = :id')
->execute([':ts' => time(), ':id' => $id]);
$pdo->commit(); $pdo->commit();
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]); json_success(['deleted' => false, 'retired' => true, 'structureRevision' => $newRev]);
break;
}
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
} }

View File

@ -99,13 +99,24 @@ if ($method === 'POST') {
$opened = qdb_open_write_or_fail(); $opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened; [$pdo, $tmpDb, $lockFp] = $opened;
// Verify questionnaire exists require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
$qnStmt = $pdo->prepare(
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire WHERE questionnaireID = :id'
);
$qnStmt->execute([':id' => $qnID]); $qnStmt->execute([':id' => $qnID]);
if (!$qnStmt->fetch()) { $qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404); 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'); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare( $clStmt = $pdo->prepare(
@ -122,42 +133,24 @@ if ($method === 'POST') {
? ($tokenRec['entityID'] ?? '') ? ($tokenRec['entityID'] ?? '')
: ($clientRow['coachID'] ?? ''); : ($clientRow['coachID'] ?? '');
// Load all questions for this questionnaire: full questionID keyed by short ID $isLegacySubmit = $submitRev < $currentRev;
$qRows = $pdo->prepare( if ($isLegacySubmit) {
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn" $submitManifest = qdb_load_structure_manifest($pdo, $qnID, $submitRev);
); if ($submitManifest === null) {
$qRows->execute([':qn' => $qnID]); qdb_discard($tmpDb, $lockFp);
$shortIdMap = []; // shortId -> fullQuestionID json_error('STRUCTURE_REVISION_UNKNOWN', 'Unknown structure revision', 400);
$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);
$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));
} }
} else {
$submitManifest = qdb_build_structure_manifest($pdo, $qnID, false);
$submitManifest['structureRevision'] = $currentRev;
} }
// Load all answer options for this questionnaire: keyed by [questionID][defaultText] $maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest);
$aoRows = $pdo->prepare(" $shortIdMap = $maps['shortIdMap'];
SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points $shortIdToType = $maps['shortIdToType'];
FROM answer_option ao $optionMap = $maps['optionMap'];
JOIN question q ON q.questionID = ao.questionID $symptomParentMap = $maps['symptomParentMap'];
WHERE q.questionnaireID = :qn $freeTextMaxLen = $maps['freeTextMaxLen'];
");
$aoRows->execute([':qn' => $qnID]);
$optionMap = []; // fullQuestionID -> [defaultText -> {answerOptionID, points}]
foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionMap[$ao['questionID']][$ao['defaultText']] = [
'answerOptionID' => $ao['answerOptionID'],
'points' => (int)$ao['points'],
];
}
require_once __DIR__ . '/../lib/app_submit_validate.php'; require_once __DIR__ . '/../lib/app_submit_validate.php';
$validationErrors = qdb_validate_app_submit_payload( $validationErrors = qdb_validate_app_submit_payload(
@ -167,7 +160,8 @@ if ($method === 'POST') {
$shortIdMap, $shortIdMap,
$shortIdToType, $shortIdToType,
$symptomParentMap, $symptomParentMap,
$optionMap $optionMap,
$submitManifest
); );
if ($validationErrors !== []) { if ($validationErrors !== []) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
@ -281,9 +275,12 @@ if ($method === 'POST') {
$submittedQuestionIds[$parentQID] = true; $submittedQuestionIds[$parentQID] = true;
} }
// Re-edit uploads omit answers pruned on the device (e.g. branching changes). if (!$isLegacySubmit) {
// Drop live rows for questions not present in this payload. $activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID);
$staleQuestionIds = array_diff(array_values($shortIdMap), array_keys($submittedQuestionIds)); $staleQuestionIds = array_diff(
array_values($activeMaps['shortIdMap']),
array_keys($submittedQuestionIds)
);
if ($staleQuestionIds !== []) { if ($staleQuestionIds !== []) {
$placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?')); $placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?'));
$staleDelete = $pdo->prepare( $staleDelete = $pdo->prepare(
@ -291,10 +288,12 @@ if ($method === 'POST') {
); );
$staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds))); $staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds)));
} }
}
// Upsert completed_questionnaire record
require_once __DIR__ . '/../lib/scoring.php'; require_once __DIR__ . '/../lib/scoring.php';
$sumPoints = qdb_compute_questionnaire_score($pdo, $clientCode, $qnID); $sumPoints = $isLegacySubmit
? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest)
: qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
$pdo->prepare(" $pdo->prepare("
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
@ -323,7 +322,10 @@ if ($method === 'POST') {
$startedAt, $startedAt,
$completedAt, $completedAt,
$sumPoints, $sumPoints,
$assignedByCoach $assignedByCoach,
$submitRev,
$submitManifest,
array_keys($submittedQuestionIds)
); );
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID); qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
@ -331,7 +333,12 @@ if ($method === 'POST') {
$pdo->commit(); $pdo->commit();
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['submitted' => true, 'sumPoints' => $sumPoints]); json_success([
'submitted' => true,
'sumPoints' => $sumPoints,
'structureRevision' => $submitRev,
'legacySubmit' => $isLegacySubmit,
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
} }
@ -473,6 +480,7 @@ if ($fetchClients) {
} }
if ($qnID) { if ($qnID) {
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); $qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]); $qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC); $qnRow = $qn->fetch(PDO::FETCH_ASSOC);
@ -481,10 +489,12 @@ if ($qnID) {
json_error('NOT_FOUND', 'Questionnaire not found', 404); json_error('NOT_FOUND', 'Questionnaire not found', 404);
} }
$stmt = $pdo->prepare(" $structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
SELECT questionID, defaultText, type, orderIndex, configJson $stmt = $pdo->prepare(
FROM question WHERE questionnaireID = :id ORDER BY orderIndex 'SELECT questionID, defaultText, type, orderIndex, configJson
"); FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
ORDER BY orderIndex'
);
$stmt->execute([':id' => $qnID]); $stmt->execute([':id' => $qnID]);
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC); $dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
@ -547,10 +557,11 @@ if ($qnID) {
} }
} }
$ao = $pdo->prepare(" $ao = $pdo->prepare(
SELECT defaultText, points, nextQuestionId 'SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
"); ORDER BY orderIndex'
);
$ao->execute([':qid' => $dbQ['questionID']]); $ao->execute([':qid' => $dbQ['questionID']]);
$opts = $ao->fetchAll(PDO::FETCH_ASSOC); $opts = $ao->fetchAll(PDO::FETCH_ASSOC);
@ -578,14 +589,18 @@ if ($qnID) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'meta' => ['id' => $qnID], 'meta' => ['id' => $qnID],
'structureRevision' => $structureRevision,
'questions' => $questions, 'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]); ]);
} }
// Default: ordered questionnaire list // Default: ordered questionnaire list
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$rows = $pdo->query(" $rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire FROM questionnaire
WHERE state = 'active' WHERE state = 'active'
ORDER BY orderIndex ORDER BY orderIndex
@ -597,6 +612,7 @@ foreach ($rows as $r) {
'id' => $r['questionnaireID'], 'id' => $r['questionnaireID'],
'name' => $r['name'], 'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'], 'showPoints' => (bool)(int)$r['showPoints'],
'structureRevision' => max(1, (int)$r['structureRevision']),
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(), 'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
]; ];
$cat = trim($r['categoryKey'] ?? ''); $cat = trim($r['categoryKey'] ?? '');

View File

@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/scoring.php'; require_once __DIR__ . '/../lib/scoring.php';
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web(); $tokenRec = require_valid_token_web();
@ -14,23 +15,34 @@ case 'GET':
try { try {
$opened = qdb_open_read_or_fail(); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened; [$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare(" $includeRetired = !empty($_GET['includeRetired']);
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson $qWhere = 'questionnaireID = :qid';
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex if (!$includeRetired) {
"); $qWhere .= ' AND ' . qdb_active_questions_clause('question');
}
$stmt = $pdo->prepare(
"SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson,
retiredAt, retiredInRevision
FROM question WHERE $qWhere ORDER BY orderIndex"
);
$stmt->execute([':qid' => $qnID]); $stmt->execute([':qid' => $qnID]);
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC); $questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
foreach ($questions as &$q) { foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired']; $q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex']; $q['orderIndex'] = (int)$q['orderIndex'];
$q['retiredAt'] = (int)($q['retiredAt'] ?? 0);
$q['retiredInRevision'] = (int)($q['retiredInRevision'] ?? 0);
$q['retired'] = $q['retiredAt'] > 0;
$cfg = qdb_parse_config_json($q['configJson']); $cfg = qdb_parse_config_json($q['configJson']);
$q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE); $q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE);
$q['questionKey'] = qdb_question_key($cfg, $q['defaultText']); $q['questionKey'] = qdb_question_key($cfg, $q['defaultText']);
$q['localId'] = qdb_question_local_id($q['questionID'], $qnID); $q['localId'] = qdb_question_local_id($q['questionID'], $qnID);
$ao = $pdo->prepare(" $ao = $pdo->prepare(
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId "SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId, retiredAt
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex FROM answer_option WHERE questionID = :qid AND " . qdb_active_options_clause('answer_option') . '
"); ORDER BY orderIndex'
);
$ao->execute([':qid' => $q['questionID']]); $ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC); $q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) { foreach ($q['answerOptions'] as &$opt) {
@ -51,7 +63,10 @@ case 'GET':
} }
unset($q); unset($q);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['questions' => $questions]); json_success([
'questions' => $questions,
'structureRevision' => $structureRevision,
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null);
} }
@ -119,8 +134,11 @@ case 'POST':
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) { if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules); qdb_sync_question_score_rules($pdo, $id, $scoreRules);
} }
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_added');
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['question' => [ json_success([
'structureRevision' => $newRev,
'question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
'questionKey' => $questionKey, 'localId' => $localId, 'questionKey' => $questionKey, 'localId' => $localId,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
@ -129,7 +147,8 @@ case 'POST':
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [], ? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true) 'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [], ? qdb_get_score_rules_for_question($pdo, $id) : [],
]]); ],
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
} }
@ -175,6 +194,17 @@ case 'PUT':
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null) $body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
); );
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE); $configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
$oldKey = qdb_question_key($oldCfg, $row['defaultText']);
$oldSymptoms = json_encode($oldCfg['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
$newSymptoms = json_encode($config['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
$structuralChange = ($type !== (string)$row['type'])
|| ($req !== (int)$row['isRequired'])
|| ($questionKey !== $oldKey)
|| ($newSymptoms !== $oldSymptoms);
if ((int)($row['retiredAt'] ?? 0) > 0) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'Cannot edit a retired question', 400);
}
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id") $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]); ->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'question', $id, $text); qdb_upsert_source_translation($pdo, 'question', $id, $text);
@ -186,8 +216,14 @@ case 'PUT':
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) { if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules); qdb_sync_question_score_rules($pdo, $id, $scoreRules);
} }
$newRev = null;
if ($structuralChange) {
$newRev = qdb_bump_structure_revision($pdo, (string)$row['questionnaireID'], 'question_updated');
}
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['question' => [ json_success([
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
'question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'defaultText' => $text, 'questionKey' => $questionKey, 'defaultText' => $text, 'questionKey' => $questionKey,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
@ -196,7 +232,8 @@ case 'PUT':
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [], ? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true) 'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [], ? qdb_get_score_rules_for_question($pdo, $id) : [],
]]); ],
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
} }
@ -211,22 +248,53 @@ case 'DELETE':
$id = $body['questionID']; $id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try { try {
$pdo->exec("PRAGMA foreign_keys = OFF;"); $existing = $pdo->prepare('SELECT questionnaireID, retiredAt 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);
}
$qnID = (string)$row['questionnaireID'];
if ((int)($row['retiredAt'] ?? 0) > 0) {
qdb_discard($tmpDb, $lockFp);
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID)]);
break;
}
$pdo->beginTransaction(); $pdo->beginTransaction();
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id"); $newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_removed');
if (qdb_question_has_client_data($pdo, $id)) {
qdb_retire_question($pdo, $id, $newRev);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([
'deleted' => false,
'retired' => true,
'structureRevision' => $newRev,
]);
break;
}
$pdo->exec('PRAGMA foreign_keys = OFF;');
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :id');
$aoIds->execute([':id' => $id]); $aoIds->execute([':id' => $id]);
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) { 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_translation WHERE answerOptionID = :id')->execute([':id' => $aoid]);
} }
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare('DELETE FROM answer_option WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_score_rule WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_translation 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->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]); $pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->commit(); $pdo->commit();
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]); json_success([
'deleted' => true,
'retired' => false,
'structureRevision' => $newRev,
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
} }

View File

@ -1,8 +1,11 @@
<?php <?php
require_once __DIR__ . '/questionnaire_structure.php';
/** /**
* Validate Android app questionnaire submit payloads before persisting answers. * Validate Android app questionnaire submit payloads before persisting answers.
* *
* @param array<string, mixed>|null $manifest Structure manifest for legacy revision submits
* @return list<array{questionID: string, code: string, message: string}> * @return list<array{questionID: string, code: string, message: string}>
*/ */
function qdb_validate_app_submit_payload( function qdb_validate_app_submit_payload(
@ -12,7 +15,8 @@ function qdb_validate_app_submit_payload(
array $shortIdMap, array $shortIdMap,
array $shortIdToType, array $shortIdToType,
array $symptomParentMap, array $symptomParentMap,
array $optionMap array $optionMap,
?array $manifest = null
): array { ): array {
$errors = []; $errors = [];
@ -124,8 +128,13 @@ function qdb_validate_app_submit_payload(
} }
} }
if ($manifest !== null) {
return array_merge($errors, qdb_validate_required_answers_from_manifest($manifest, $answeredShort));
}
$qStmt = $pdo->prepare( $qStmt = $pdo->prepare(
'SELECT questionID, type, isRequired, configJson FROM question WHERE questionnaireID = :qn' 'SELECT questionID, type, isRequired, configJson FROM question
WHERE questionnaireID = :qn AND ' . qdb_active_questions_clause('question')
); );
$qStmt->execute([':qn' => $qnID]); $qStmt->execute([':qn' => $qnID]);
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) { foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
@ -169,3 +178,44 @@ function qdb_validate_app_submit_payload(
return $errors; return $errors;
} }
/**
* @return list<array{questionID: string, code: string, message: string}>
*/
function qdb_validate_required_answers_from_manifest(array $manifest, array $answeredShort): array {
$errors = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry) || empty($qEntry['isRequired'])) {
continue;
}
$shortId = (string)($qEntry['shortId'] ?? '');
$type = (string)($qEntry['type'] ?? '');
if ($shortId === '') {
continue;
}
if ($type === 'glass_scale_question') {
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
if (empty($answeredShort[$sk])) {
$errors[] = [
'questionID' => $sk,
'code' => 'MISSING_ANSWER',
'message' => 'Required symptom is not answered',
];
}
}
continue;
}
if (empty($answeredShort[$shortId])) {
$errors[] = [
'questionID' => $shortId,
'code' => 'MISSING_ANSWER',
'message' => 'Required question is not answered',
];
}
}
return $errors;
}

View File

@ -0,0 +1,414 @@
<?php
/**
* Questionnaire structure revisions: bump, snapshot manifests, retire questions.
*/
function qdb_active_questions_clause(string $alias = 'question'): string {
return "COALESCE($alias.retiredAt, 0) = 0";
}
function qdb_active_options_clause(string $alias = 'answer_option'): string {
return "COALESCE($alias.retiredAt, 0) = 0";
}
function qdb_questionnaire_structure_revision(PDO $pdo, string $qnID): int {
$stmt = $pdo->prepare(
'SELECT COALESCE(structureRevision, 1) FROM questionnaire WHERE questionnaireID = :id'
);
$stmt->execute([':id' => $qnID]);
$rev = $stmt->fetchColumn();
return $rev !== false ? max(1, (int)$rev) : 1;
}
/**
* @return array<string, mixed>
*/
function qdb_build_structure_manifest(PDO $pdo, string $qnID, bool $includeRetired = false): array {
$where = 'questionnaireID = :qn';
if (!$includeRetired) {
$where .= ' AND ' . qdb_active_questions_clause('question');
}
$qStmt = $pdo->prepare(
"SELECT questionID, type, isRequired, configJson, defaultText, retiredAt
FROM question WHERE $where ORDER BY orderIndex"
);
$qStmt->execute([':qn' => $qnID]);
$questions = [];
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
$fullId = $qRow['questionID'];
$shortId = qdb_question_local_id($fullId, $qnID);
$config = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$qKey = qdb_question_key($config, $qRow['defaultText']);
$optWhere = 'questionID = :qid';
if (!$includeRetired) {
$optWhere .= ' AND ' . qdb_active_options_clause('answer_option');
}
$aoStmt = $pdo->prepare(
"SELECT defaultText, points FROM answer_option WHERE $optWhere ORDER BY orderIndex"
);
$aoStmt->execute([':qid' => $fullId]);
$options = [];
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$options[] = [
'key' => $ao['defaultText'],
'points' => (int)$ao['points'],
];
}
$entry = [
'questionID' => $fullId,
'shortId' => $shortId,
'questionKey'=> $qKey,
'type' => $qRow['type'] ?? '',
'isRequired' => (int)($qRow['isRequired'] ?? 0) === 1,
'options' => $options,
];
if ($includeRetired && (int)($qRow['retiredAt'] ?? 0) > 0) {
$entry['retired'] = true;
}
if (($qRow['type'] ?? '') === 'glass_scale_question' && !empty($config['symptoms'])) {
$entry['symptoms'] = array_values(array_map('strval', (array)$config['symptoms']));
}
$questions[] = $entry;
}
return [
'questionnaireID' => $qnID,
'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID),
'questions' => $questions,
];
}
/**
* @return array<string, mixed>|null
*/
function qdb_load_structure_manifest(PDO $pdo, string $qnID, int $revision): ?array {
$stmt = $pdo->prepare(
'SELECT manifestJson FROM questionnaire_structure_snapshot
WHERE questionnaireID = :qn AND structureRevision = :rev'
);
$stmt->execute([':qn' => $qnID, ':rev' => $revision]);
$json = $stmt->fetchColumn();
if ($json === false || $json === '') {
return null;
}
$decoded = json_decode((string)$json, true);
return is_array($decoded) ? $decoded : null;
}
function qdb_save_structure_snapshot(PDO $pdo, string $qnID, int $revision, array $manifest): void {
$json = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
$json = '{}';
}
$pdo->prepare(
'INSERT INTO questionnaire_structure_snapshot (questionnaireID, structureRevision, createdAt, manifestJson)
VALUES (:qn, :rev, :ts, :mj)
ON CONFLICT(questionnaireID, structureRevision) DO UPDATE SET
manifestJson = excluded.manifestJson,
createdAt = excluded.createdAt'
)->execute([
':qn' => $qnID,
':rev' => $revision,
':ts' => time(),
':mj' => $json,
]);
}
/**
* Backfill revision-1 snapshots for questionnaires missing any snapshot row.
*/
function qdb_backfill_structure_snapshots(PDO $pdo): bool {
$ids = $pdo->query('SELECT questionnaireID FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN);
$changed = false;
foreach ($ids as $qnID) {
$qnID = (string)$qnID;
$chk = $pdo->prepare(
'SELECT 1 FROM questionnaire_structure_snapshot
WHERE questionnaireID = :qn LIMIT 1'
);
$chk->execute([':qn' => $qnID]);
if ($chk->fetchColumn()) {
continue;
}
$rev = qdb_questionnaire_structure_revision($pdo, $qnID);
$manifest = qdb_build_structure_manifest($pdo, $qnID, true);
$manifest['structureRevision'] = $rev;
qdb_save_structure_snapshot($pdo, $qnID, $rev, $manifest);
$changed = true;
}
return $changed;
}
function qdb_bump_structure_revision(PDO $pdo, string $qnID, string $reason = ''): int {
$current = qdb_questionnaire_structure_revision($pdo, $qnID);
$manifest = qdb_build_structure_manifest($pdo, $qnID, false);
$manifest['structureRevision'] = $current;
qdb_save_structure_snapshot($pdo, $qnID, $current, $manifest);
$newRev = $current + 1;
$pdo->prepare(
'UPDATE questionnaire SET structureRevision = :rev, structureChangedAt = :ts WHERE questionnaireID = :qn'
)->execute([':rev' => $newRev, ':ts' => time(), ':qn' => $qnID]);
return $newRev;
}
function qdb_questionnaire_id_for_question(PDO $pdo, string $questionID): ?string {
$stmt = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :qid');
$stmt->execute([':qid' => $questionID]);
$id = $stmt->fetchColumn();
return $id !== false ? (string)$id : null;
}
function qdb_option_has_client_data(PDO $pdo, string $answerOptionID): bool {
$stmt = $pdo->prepare('SELECT 1 FROM client_answer WHERE answerOptionID = :id LIMIT 1');
$stmt->execute([':id' => $answerOptionID]);
if ($stmt->fetchColumn()) {
return true;
}
$chk = $pdo->query(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
);
if (!$chk || !$chk->fetchColumn()) {
return false;
}
$stmt = $pdo->prepare(
'SELECT 1 FROM client_answer_submission WHERE answerOptionID = :id LIMIT 1'
);
$stmt->execute([':id' => $answerOptionID]);
return (bool)$stmt->fetchColumn();
}
function qdb_question_has_client_data(PDO $pdo, string $questionID): bool {
$stmt = $pdo->prepare('SELECT 1 FROM client_answer WHERE questionID = :qid LIMIT 1');
$stmt->execute([':qid' => $questionID]);
if ($stmt->fetchColumn()) {
return true;
}
$chk = $pdo->query(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
);
if (!$chk || !$chk->fetchColumn()) {
return false;
}
$stmt = $pdo->prepare(
'SELECT 1 FROM client_answer_submission WHERE questionID = :qid LIMIT 1'
);
$stmt->execute([':qid' => $questionID]);
return (bool)$stmt->fetchColumn();
}
function qdb_retire_question(PDO $pdo, string $questionID, int $revision): void {
$pdo->prepare(
'UPDATE question SET retiredAt = :ts, retiredInRevision = :rev
WHERE questionID = :qid AND COALESCE(retiredAt, 0) = 0'
)->execute([':ts' => time(), ':rev' => $revision, ':qid' => $questionID]);
}
/**
* Build submit maps from a structure manifest (current or legacy).
*
* @return array{
* shortIdMap: array<string, string>,
* shortIdToType: array<string, string>,
* optionMap: array<string, array<string, array{answerOptionID: string, points: int}>>,
* symptomParentMap: array<string, string>,
* freeTextMaxLen: array<string, int>,
* manifestQuestionIds: list<string>
* }
*/
function qdb_submit_maps_from_manifest(PDO $pdo, string $qnID, array $manifest): array {
$shortIdMap = [];
$shortIdToType = [];
$optionMap = [];
$symptomParentMap = [];
$freeTextMaxLen = [];
$manifestQuestionIds = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
$shortId = (string)($qEntry['shortId'] ?? '');
if ($fullId === '' || $shortId === '') {
continue;
}
$type = (string)($qEntry['type'] ?? '');
$shortIdMap[$shortId] = $fullId;
$shortIdToType[$shortId] = $type;
$manifestQuestionIds[] = $fullId;
if ($type === 'free_text') {
$cfgStmt = $pdo->prepare('SELECT configJson FROM question WHERE questionID = :qid');
$cfgStmt->execute([':qid' => $fullId]);
$cfgJson = $cfgStmt->fetchColumn();
$cfg = json_decode($cfgJson ?: '{}', true) ?: [];
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
}
$optionMap[$fullId] = [];
foreach ($qEntry['options'] ?? [] as $opt) {
if (!is_array($opt)) {
continue;
}
$key = (string)($opt['key'] ?? '');
if ($key === '') {
continue;
}
$aoStmt = $pdo->prepare(
'SELECT answerOptionID, points FROM answer_option
WHERE questionID = :qid AND defaultText = :k LIMIT 1'
);
$aoStmt->execute([':qid' => $fullId, ':k' => $key]);
$aoRow = $aoStmt->fetch(PDO::FETCH_ASSOC);
$optionMap[$fullId][$key] = [
'answerOptionID' => $aoRow ? (string)$aoRow['answerOptionID'] : '',
'points' => $aoRow ? (int)$aoRow['points'] : (int)($opt['points'] ?? 0),
];
}
if ($type === 'glass_scale_question') {
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk !== '') {
$symptomParentMap[$sk] = $fullId;
}
}
}
}
if ($symptomParentMap === []) {
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
}
return [
'shortIdMap' => $shortIdMap,
'shortIdToType' => $shortIdToType,
'optionMap' => $optionMap,
'symptomParentMap' => $symptomParentMap,
'freeTextMaxLen' => $freeTextMaxLen,
'manifestQuestionIds' => $manifestQuestionIds,
];
}
/**
* Build submit maps from active DB questions (current revision).
*
* @return array<string, mixed>
*/
function qdb_submit_maps_from_active_questions(PDO $pdo, string $qnID): array {
$manifest = qdb_build_structure_manifest($pdo, $qnID, false);
$manifest['structureRevision'] = qdb_questionnaire_structure_revision($pdo, $qnID);
return qdb_submit_maps_from_manifest($pdo, $qnID, $manifest);
}
/**
* @return list<array{field: string, header: string, questionID: string, type: string}>
*/
function qdb_result_columns_from_manifest(array $manifest): array {
$cols = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
$shortId = (string)($qEntry['shortId'] ?? '');
$qKey = (string)($qEntry['questionKey'] ?? '');
$type = (string)($qEntry['type'] ?? '');
if ($fullId === '') {
continue;
}
$header = $qKey !== '' ? $qKey : ($shortId !== '' ? $shortId : $fullId);
$cols[] = [
'field' => $header,
'header' => $header,
'questionID' => $fullId,
'type' => $type,
];
if ($type === 'glass_scale_question') {
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
$cols[] = [
'field' => $sk,
'header' => $sk,
'questionID' => $fullId,
'type' => 'glass_symptom',
'symptomKey' => $sk,
];
}
}
}
return $cols;
}
function qdb_compute_questionnaire_score_from_manifest(
PDO $pdo,
string $clientCode,
array $manifest
): int {
require_once __DIR__ . '/scoring.php';
$qnID = (string)($manifest['questionnaireID'] ?? '');
if ($qnID === '') {
return 0;
}
$questionIDs = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (is_array($qEntry) && !empty($qEntry['questionID'])) {
$questionIDs[] = (string)$qEntry['questionID'];
}
}
if ($questionIDs === []) {
return 0;
}
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
$aStmt = $pdo->prepare(
"SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
ao.defaultText AS optionKey
FROM client_answer ca
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
WHERE ca.clientCode = ? AND ca.questionID IN ($ph)"
);
$aStmt->execute(array_merge([$clientCode], $questionIDs));
$answersByQuestion = [];
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
$answersByQuestion[$a['questionID']] = $a;
}
$total = 0;
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
$type = (string)($qEntry['type'] ?? '');
$optionPoints = [];
foreach ($qEntry['options'] ?? [] as $opt) {
if (is_array($opt) && isset($opt['key'])) {
$optionPoints[(string)$opt['key']] = (int)($opt['points'] ?? 0);
}
}
$qRow = [
'questionID' => $fullId,
'type' => $type,
'configJson' => json_encode(
['symptoms' => $qEntry['symptoms'] ?? []],
JSON_UNESCAPED_UNICODE
),
];
$total += qdb_score_points_for_question(
$pdo,
$qRow,
$answersByQuestion[$fullId] ?? null,
$optionPoints
);
}
return $total;
}

View File

@ -19,6 +19,8 @@ function qdb_read_db_open(): array {
} }
[$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db(); [$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db();
// Materialize may persist a migration (updates QDB_PATH mtime); refresh signature before caching.
$signature = qdb_read_cache_signature();
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd); qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
return [$pdo, QDB_READONLY_CACHE_HANDLE, null]; return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
} }

View File

@ -3,6 +3,110 @@
* Questionnaire submission versioning (archive each coach upload). * Questionnaire submission versioning (archive each coach upload).
*/ */
require_once __DIR__ . '/questionnaire_structure.php';
/**
* Build display columns from a stored structure manifest (historical submissions).
*
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
*/
function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
$stringLabelCache = [];
$resultColumns = [];
$questions = [];
$optionTextMap = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
if ($fullId === '') {
continue;
}
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionID = :id'
);
$qStmt->execute([':id' => $fullId]);
$qRow = $qStmt->fetch(PDO::FETCH_ASSOC);
if (!$qRow) {
$symptoms = $qEntry['symptoms'] ?? [];
$qRow = [
'questionID' => $fullId,
'defaultText' => (string)($qEntry['questionKey'] ?? $qEntry['shortId'] ?? $fullId),
'type' => (string)($qEntry['type'] ?? ''),
'orderIndex' => 0,
'configJson' => json_encode(
['symptoms' => is_array($symptoms) ? $symptoms : []],
JSON_UNESCAPED_UNICODE
),
];
}
$questions[] = $qRow;
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$type = $qRow['type'] ?? '';
if ($type === 'glass_scale_question') {
$symptoms = $cfg['symptoms'] ?? $qEntry['symptoms'] ?? [];
if (is_array($symptoms) && $symptoms !== []) {
foreach ($symptoms as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
$resultColumns[] = [
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
'questionID' => $fullId,
'symptomKey' => $sk,
'kind' => 'glass_symptom',
'question' => $qRow,
];
}
continue;
}
}
$resultColumns[] = [
'header' => qdb_question_german_label($pdo, $qRow),
'questionID' => $fullId,
'symptomKey' => null,
'kind' => 'question',
'question' => $qRow,
];
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
$aoStmt->execute([':qid' => $fullId]);
$dbOptions = $aoStmt->fetchAll(PDO::FETCH_ASSOC);
if ($dbOptions !== []) {
foreach ($dbOptions as $ao) {
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
$pdo,
$ao['answerOptionID'],
$ao['defaultText']
);
}
} else {
foreach ($qEntry['options'] ?? [] as $opt) {
if (!is_array($opt)) {
continue;
}
$key = (string)($opt['key'] ?? '');
if ($key !== '') {
$optionTextMap[$key] = $key;
}
}
}
}
return [
'questions' => $questions,
'resultColumns' => $resultColumns,
'optionTextMap' => $optionTextMap,
'stringLabelCache' => $stringLabelCache,
];
}
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int { function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission 'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
@ -24,11 +128,21 @@ function qdb_record_submission_after_submit(
?int $completedAt, ?int $completedAt,
int $sumPoints, int $sumPoints,
string $assignedByCoach, string $assignedByCoach,
int $structureRevision = 1,
?array $structureManifest = null,
?array $questionIdsToCopy = null,
?int $submittedAt = null ?int $submittedAt = null
): string { ): string {
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID); $version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
$submissionID = bin2hex(random_bytes(16)); $submissionID = bin2hex(random_bytes(16));
$submittedAtValue = $submittedAt ?? $completedAt ?? time(); $submittedAtValue = $submittedAt ?? $completedAt ?? time();
$snapshotJson = '{}';
if ($structureManifest !== null) {
$encoded = json_encode($structureManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded !== false) {
$snapshotJson = $encoded;
}
}
$cq = $pdo->prepare( $cq = $pdo->prepare(
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn' 'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
@ -40,11 +154,13 @@ function qdb_record_submission_after_submit(
'INSERT INTO questionnaire_submission ( 'INSERT INTO questionnaire_submission (
submissionID, clientCode, questionnaireID, version, submittedAt, submissionID, clientCode, questionnaireID, version, submittedAt,
submittedByUserID, submittedByRole, assignedByCoach, submittedByUserID, submittedByRole, assignedByCoach,
status, startedAt, completedAt, sumPoints status, startedAt, completedAt, sumPoints,
structureRevision, structureSnapshotJson
) VALUES ( ) VALUES (
:sid, :cc, :qn, :ver, :sat, :sid, :cc, :qn, :ver, :sat,
:uid, :role, :abc, :uid, :role, :abc,
:st, :sa, :ca, :sp :st, :sa, :ca, :sp,
:srev, :snap
)' )'
)->execute([ )->execute([
':sid' => $submissionID, ':sid' => $submissionID,
@ -59,8 +175,21 @@ function qdb_record_submission_after_submit(
':sa' => $startedAt, ':sa' => $startedAt,
':ca' => $completedAt, ':ca' => $completedAt,
':sp' => $sumPoints, ':sp' => $sumPoints,
':srev' => max(1, $structureRevision),
':snap' => $snapshotJson,
]); ]);
if ($questionIdsToCopy !== null && $questionIdsToCopy !== []) {
$questionIdsToCopy = array_values(array_unique(array_filter($questionIdsToCopy, 'is_string')));
$ph = implode(',', array_fill(0, count($questionIdsToCopy), '?'));
$copy = $pdo->prepare(
"INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT ?, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($ph)"
);
$copy->execute(array_merge([$submissionID, $clientCode], $questionIdsToCopy));
} else {
$copy = $pdo->prepare( $copy = $pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) 'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
@ -70,6 +199,7 @@ function qdb_record_submission_after_submit(
)' )'
); );
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]); $copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
}
return $submissionID; return $submissionID;
} }
@ -169,6 +299,7 @@ function qdb_export_all_versions_rows(
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole, SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints, qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus, qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
qs.structureRevision, qs.structureSnapshotJson,
cl.clientCode, cl.coachID, cl.clientCode, cl.coachID,
co.username AS coachUsername, co.username AS coachUsername,
sv.username AS supervisorUsername sv.username AS supervisorUsername
@ -184,16 +315,7 @@ function qdb_export_all_versions_rows(
$stmt->execute($params); $stmt->execute($params);
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC); $submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID'); $defaultQuestionIDs = array_column($questions, 'questionID');
$qPlaceholders = !empty($questionIDs)
? implode(',', array_fill(0, count($questionIDs), '?'))
: "'__none__'";
$answerStmt = $pdo->prepare("
SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer_submission
WHERE submissionID = ? AND questionID IN ($qPlaceholders)
");
$userMap = []; $userMap = [];
$userStmt = $pdo->query('SELECT userID, username FROM users'); $userStmt = $pdo->query('SELECT userID, username FROM users');
@ -203,11 +325,24 @@ function qdb_export_all_versions_rows(
$rows = []; $rows = [];
foreach ($submissions as $s) { foreach ($submissions as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$submissionColumns = $snapCtx['resultColumns'];
$submissionOptionMap = $snapCtx['optionTextMap'];
$submissionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$submissionColumns = $resultColumns;
$submissionOptionMap = $optionTextMap;
$submissionQuestionIDs = $defaultQuestionIDs;
}
$row = [ $row = [
'submissionID' => $s['submissionID'], 'submissionID' => $s['submissionID'],
'version' => (string)(int)$s['version'], 'version' => (string)(int)$s['version'],
'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'submittedByRole'=> $s['submittedByRole'] ?? '', 'submittedByRole' => $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''), 'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
'clientCode' => $s['clientCode'], 'clientCode' => $s['clientCode'],
'coach' => $s['coachUsername'] ?? $s['coachID'], 'coach' => $s['coachUsername'] ?? $s['coachID'],
@ -218,17 +353,18 @@ function qdb_export_all_versions_rows(
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', 'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
]; ];
$bindParams = array_merge([$s['submissionID']], $questionIDs); $answerMap = qdb_load_client_answer_map(
$answerStmt->execute($bindParams); $pdo,
$answerMap = []; $s['clientCode'],
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) { $submissionQuestionIDs,
$answerMap[$a['questionID']] = $a; 'client_answer_submission',
} $s['submissionID']
);
foreach ($resultColumns as $col) { foreach ($submissionColumns as $col) {
$qid = $col['questionID']; $qid = $col['questionID'];
$a = $answerMap[$qid] ?? null; $a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap); $row[$col['header']] = qdb_results_column_cell_value($col, $a, $submissionOptionMap);
} }
$rows[] = $row; $rows[] = $row;
} }
@ -671,7 +807,8 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
); );
$subStmt = $pdo->prepare( $subStmt = $pdo->prepare(
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt 'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt,
structureRevision, structureSnapshotJson
FROM questionnaire_submission FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn WHERE clientCode = :cc AND questionnaireID = :qn
ORDER BY version DESC' ORDER BY version DESC'
@ -679,19 +816,33 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]); $subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$submissions = []; $submissions = [];
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) { foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$versionColumns = $snapCtx['resultColumns'];
$versionOptionMap = $snapCtx['optionTextMap'];
$versionStringCache = $snapCtx['stringLabelCache'];
$versionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$versionColumns = $resultColumns;
$versionOptionMap = $optionTextMap;
$versionStringCache = $stringLabelCache;
$versionQuestionIDs = $questionIDs;
}
$subMap = qdb_load_client_answer_map( $subMap = qdb_load_client_answer_map(
$pdo, $pdo,
$clientCode, $clientCode,
$questionIDs, $versionQuestionIDs,
'client_answer_submission', 'client_answer_submission',
$s['submissionID'] $s['submissionID']
); );
$versionAnswers = qdb_answers_display_rows( $versionAnswers = qdb_answers_display_rows(
$pdo, $pdo,
$resultColumns, $versionColumns,
$subMap, $subMap,
$optionTextMap, $versionOptionMap,
$stringLabelCache, $versionStringCache,
$liveMap $liveMap
); );
$changedCount = count(array_filter( $changedCount = count(array_filter(
@ -701,6 +852,7 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
$submissions[] = [ $submissions[] = [
'submissionID' => $s['submissionID'], 'submissionID' => $s['submissionID'],
'version' => (int)$s['version'], 'version' => (int)$s['version'],
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'status' => $s['status'] ?? '', 'status' => $s['status'] ?? '',
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null, 'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,

View File

@ -52,7 +52,18 @@ CREATE TABLE IF NOT EXISTS questionnaire (
orderIndex INTEGER NOT NULL DEFAULT 0, orderIndex INTEGER NOT NULL DEFAULT 0,
showPoints 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 '' categoryKey TEXT NOT NULL DEFAULT '',
structureRevision INTEGER NOT NULL DEFAULT 1,
structureChangedAt INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS questionnaire_structure_snapshot (
questionnaireID TEXT NOT NULL,
structureRevision INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
manifestJson TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (questionnaireID, structureRevision),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS question ( CREATE TABLE IF NOT EXISTS question (
@ -63,6 +74,8 @@ CREATE TABLE IF NOT EXISTS question (
orderIndex INTEGER NOT NULL DEFAULT 0, orderIndex INTEGER NOT NULL DEFAULT 0,
isRequired INTEGER NOT NULL DEFAULT 0, isRequired INTEGER NOT NULL DEFAULT 0,
configJson TEXT NOT NULL DEFAULT '{}', configJson TEXT NOT NULL DEFAULT '{}',
retiredAt INTEGER NOT NULL DEFAULT 0,
retiredInRevision INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
); );
@ -73,6 +86,7 @@ CREATE TABLE IF NOT EXISTS answer_option (
points INTEGER NOT NULL DEFAULT 0, points INTEGER NOT NULL DEFAULT 0,
orderIndex INTEGER NOT NULL DEFAULT 0, orderIndex INTEGER NOT NULL DEFAULT 0,
nextQuestionId TEXT NOT NULL DEFAULT '', nextQuestionId TEXT NOT NULL DEFAULT '',
retiredAt INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID) FOREIGN KEY(questionID) REFERENCES question(questionID)
); );
@ -154,6 +168,8 @@ CREATE TABLE IF NOT EXISTS questionnaire_submission (
startedAt INTEGER, startedAt INTEGER,
completedAt INTEGER, completedAt INTEGER,
sumPoints INTEGER, sumPoints INTEGER,
structureRevision INTEGER NOT NULL DEFAULT 1,
structureSnapshotJson TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(clientCode) REFERENCES client(clientCode), FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID), FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID), FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppSubmitLegacyTest extends QdbTestCase
{
public function testLegacySubmitAcceptsRetiredQuestionAfterBump(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
null,
null,
1
));
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'legacy_test_retire');
qdb_retire_question($pdo, $f->questionId, $rev);
$currentRev = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
qdb_save($tmpDb, $lockFp);
$this->assertGreaterThan(1, $currentRev);
$res = $this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
null,
null,
1
);
$this->assertApiOk($res);
$this->assertTrue($res['data']['submitted'] ?? false);
$this->assertTrue($res['data']['legacySubmit'] ?? false);
$this->assertSame(1, $res['data']['structureRevision'] ?? null);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$sub = $pdo->prepare(
'SELECT structureRevision, structureSnapshotJson FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$row = $sub->fetch(\PDO::FETCH_ASSOC);
$this->assertSame(1, (int)($row['structureRevision'] ?? 0));
$snap = json_decode($row['structureSnapshotJson'] ?? '{}', true) ?: [];
$this->assertNotEmpty($snap['questions'] ?? []);
$archived = $pdo->prepare(
'SELECT COUNT(*) FROM client_answer_submission cas
INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID
WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn AND cas.questionID = :qid'
);
$archived->execute([
':cc' => $f->clientCode,
':qn' => $f->questionnaireId,
':qid' => $f->questionId,
]);
$this->assertGreaterThan(0, (int)$archived->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
}

View File

@ -137,18 +137,24 @@ abstract class QdbTestCase extends PHPUnitTestCase
array $answers, array $answers,
?int $startedAt = null, ?int $startedAt = null,
?int $completedAt = null, ?int $completedAt = null,
?int $structureRevision = null,
): array { ): array {
$now = time(); $now = time();
$startedAt ??= $now - 60; $startedAt ??= $now - 60;
$completedAt ??= $now; $completedAt ??= $now;
return $this->api()->encryptedPost($token, 'app_questionnaires', [ $payload = [
'questionnaireID' => $questionnaireId, 'questionnaireID' => $questionnaireId,
'clientCode' => $clientCode, 'clientCode' => $clientCode,
'answers' => $answers, 'answers' => $answers,
'startedAt' => $startedAt, 'startedAt' => $startedAt,
'completedAt' => $completedAt, 'completedAt' => $completedAt,
]); ];
if ($structureRevision !== null) {
$payload['structureRevision'] = $structureRevision;
}
return $this->api()->encryptedPost($token, 'app_questionnaires', $payload);
} }
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int protected function submissionVersionCount(string $clientCode, string $questionnaireId): int

View File

@ -37,6 +37,28 @@ final class AppSubmitValidateTest extends QdbTestCase
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? ''); $this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
} }
public function testUnknownQuestionAllowedInLegacyManifest(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$manifest = qdb_build_structure_manifest($pdo, $f->questionnaireId, true);
$maps = qdb_submit_maps_from_manifest($pdo, $f->questionnaireId, $manifest);
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
$maps['shortIdMap'],
$maps['shortIdToType'],
$maps['symptomParentMap'],
$maps['optionMap'],
$manifest
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame([], $errors);
}
public function testValidSingleChoiceWithOptionKeyPasses(): void public function testValidSingleChoiceWithOptionKeyPasses(): void
{ {
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php'; require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\QdbTestCase;
final class QuestionnaireStructureTest extends QdbTestCase
{
public function testBumpIncrementsRevisionAndStoresSnapshot(): void
{
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
$before = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
$newRev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'test_bump');
$after = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
$this->assertSame($before + 1, $newRev);
$this->assertSame($newRev, $after);
$manifest = qdb_load_structure_manifest($pdo, $f->questionnaireId, $before);
$this->assertNotNull($manifest);
$this->assertNotEmpty($manifest['questions'] ?? []);
$this->assertSame($before, (int)($manifest['structureRevision'] ?? 0));
qdb_save($tmpDb, $lockFp);
}
public function testRetirePreservesClientAnswers(): void
{
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
$this->assertApiOk($this->submitFixtureQuestionnaire());
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
$this->assertTrue(qdb_question_has_client_data($pdo, $f->questionId));
$rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'retire_test');
qdb_retire_question($pdo, $f->questionId, $rev);
$row = $pdo->prepare('SELECT retiredAt FROM question WHERE questionID = :id');
$row->execute([':id' => $f->questionId]);
$this->assertGreaterThan(0, (int)$row->fetchColumn());
$ans = $pdo->prepare(
'SELECT COUNT(*) FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$ans->execute([':cc' => $f->clientCode, ':qid' => $f->questionId]);
$this->assertSame(1, (int)$ans->fetchColumn());
qdb_save($tmpDb, $lockFp);
}
}

View File

@ -318,6 +318,18 @@ code {
.badge-draft { background: var(--badge-draft-bg); color: var(--badge-draft-fg); } .badge-draft { background: var(--badge-draft-bg); color: var(--badge-draft-fg); }
.badge-active { background: var(--badge-active-bg); color: var(--badge-active-fg); } .badge-active { background: var(--badge-active-bg); color: var(--badge-active-fg); }
.badge-archived { background: var(--badge-archived-bg); color: var(--badge-archived-fg); } .badge-archived { background: var(--badge-archived-bg); color: var(--badge-archived-fg); }
.retired-questions-panel summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 8px;
}
.retired-question-item {
opacity: 0.85;
}
.retired-question-item .question-header {
cursor: default;
}
.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); } .badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); }
.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); } .badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); } .badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); }

View File

@ -467,6 +467,7 @@ function clientQnBlockHTML(clientCode, q) {
data-qn="${esc(q.questionnaireID)}" data-qn="${esc(q.questionnaireID)}"
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button> data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
<strong>Version ${s.version}</strong> <strong>Version ${s.version}</strong>
${s.structureRevision ? `<span class="badge badge-q">struct rev ${s.structureRevision}</span>` : ''}
${s.changedCount > 0 ${s.changedCount > 0
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>` ? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
: '<span class="client-qn-summary">Same as live</span>'} : '<span class="client-qn-summary">Same as live</span>'}

View File

@ -43,6 +43,7 @@ const GLASS_SCORE_LEVELS = [
let questionnaire = null; let questionnaire = null;
let questions = []; let questions = [];
let retiredQuestions = [];
let isNew = false; let isNew = false;
let activeTab = 'questions'; let activeTab = 'questions';
let allQuestionnaires = []; let allQuestionnaires = [];
@ -86,14 +87,19 @@ export async function editorPage(params) {
renderEditor(); renderEditor();
} else { } else {
try { try {
const quData = await apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`); const quData = await apiGet(
`questions.php?questionnaireID=${encodeURIComponent(params.id)}&includeRetired=1`
);
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id); questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
if (!questionnaire) { if (!questionnaire) {
showToast('Questionnaire not found', 'error'); showToast('Questionnaire not found', 'error');
navigate('#/questionnaires'); navigate('#/questionnaires');
return; return;
} }
questions = quData.questions || []; questionnaire.structureRevision = quData.structureRevision ?? questionnaire.structureRevision ?? 1;
const allQs = quData.questions || [];
questions = allQs.filter(q => !q.retired);
retiredQuestions = allQs.filter(q => q.retired);
document.getElementById('editorTitle').textContent = questionnaire.name; document.getElementById('editorTitle').textContent = questionnaire.name;
renderEditor(); renderEditor();
} catch (e) { } catch (e) {
@ -772,6 +778,11 @@ function renderEditor() {
<label>Version</label> <label>Version</label>
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}> <input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
</div> </div>
<div class="form-group">
<label>Structure revision</label>
<input type="text" value="${esc(String(questionnaire.structureRevision ?? 1))}" disabled
title="Auto-incremented when questions or options change structurally">
</div>
<div class="form-group"> <div class="form-group">
<label>State</label> <label>State</label>
<select id="metaState" ${editable ? '' : 'disabled'}> <select id="metaState" ${editable ? '' : 'disabled'}>
@ -809,6 +820,13 @@ function renderEditor() {
</div> </div>
<div id="addQuestionFormWrapper" style="display:none"></div> <div id="addQuestionFormWrapper" style="display:none"></div>
<ul class="question-list" id="questionList"></ul> <ul class="question-list" id="questionList"></ul>
<div id="retiredQuestionsSection" style="display:none;margin-top:20px">
<details class="retired-questions-panel">
<summary>Retired questions (<span id="retiredCount">0</span>)</summary>
<p class="field-hint">Retired questions stay in upload history; counselors on older forms can still submit.</p>
<ul class="question-list retired-question-list" id="retiredQuestionList"></ul>
</details>
</div>
</div> </div>
</div> </div>
<div id="tabContent-translations" style="display:none"> <div id="tabContent-translations" style="display:none">
@ -830,6 +848,7 @@ function renderEditor() {
if (!isNew) { if (!isNew) {
initTabs(); initTabs();
renderQuestions(); renderQuestions();
renderRetiredQuestions();
if (editable) { if (editable) {
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm); document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
initDragReorder(); initDragReorder();
@ -837,6 +856,21 @@ function renderEditor() {
} }
} }
function applyStructureRevision(rev) {
if (rev == null) return;
questionnaire.structureRevision = rev;
const input = document.querySelector('.meta-form input[disabled][title*="Auto-incremented"]');
if (input) input.value = String(rev);
}
function notifyStructureBump(data) {
const rev = data?.structureRevision;
if (rev != null) {
applyStructureRevision(rev);
showToast(`Structure revision bumped to ${rev}.`, 'info');
}
}
function initTabs() { function initTabs() {
const tabBar = document.getElementById('editorTabs'); const tabBar = document.getElementById('editorTabs');
if (!tabBar) return; if (!tabBar) return;
@ -1181,6 +1215,7 @@ function showAddQuestionForm() {
questions.push(newQ); questions.push(newQ);
renderQuestions(); renderQuestions();
notifyStructureBump(qData);
showToast('Question added', 'success'); showToast('Question added', 'success');
document.getElementById('aq_text').value = ''; document.getElementById('aq_text').value = '';
@ -1211,6 +1246,30 @@ function hideAddQuestionForm() {
// ── Questions list ─────────────────────────────────────────────────────── // ── Questions list ───────────────────────────────────────────────────────
function renderRetiredQuestions() {
const section = document.getElementById('retiredQuestionsSection');
const list = document.getElementById('retiredQuestionList');
const countEl = document.getElementById('retiredCount');
if (!section || !list) return;
if (!retiredQuestions.length) {
section.style.display = 'none';
list.innerHTML = '';
if (countEl) countEl.textContent = '0';
return;
}
section.style.display = '';
if (countEl) countEl.textContent = String(retiredQuestions.length);
list.innerHTML = retiredQuestions.map((q, idx) => `
<li class="question-item retired-question-item">
<div class="question-header">
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}</span>
<span class="badge badge-archived">Retired</span>
${q.retiredInRevision ? `<span class="badge">rev ${q.retiredInRevision}</span>` : ''}
</div>
</li>
`).join('');
}
function renderQuestions() { function renderQuestions() {
const list = document.getElementById('questionList'); const list = document.getElementById('questionList');
if (!list) return; if (!list) return;
@ -1219,6 +1278,7 @@ function renderQuestions() {
// update tab label // update tab label
const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]'); const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]');
if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`; if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`;
renderRetiredQuestions();
if (!questions.length) { if (!questions.length) {
list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`; list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`;
@ -1603,12 +1663,23 @@ async function updateQuestion(questionID, fields) {
} }
async function deleteQuestion(q) { async function deleteQuestion(q) {
if (!confirm(`Delete "${q.defaultText}" and all its answers?`)) return; if (q.retired) {
showToast('This question is already retired', 'error');
return;
}
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
if (!confirm(msg)) return;
try { try {
await apiDelete('questions.php', { questionID: q.questionID }); const data = await apiDelete('questions.php', { questionID: q.questionID });
questions = questions.filter(x => x.questionID !== q.questionID); questions = questions.filter(x => x.questionID !== q.questionID);
renderQuestions(); if (data.retired) {
retiredQuestions.push({ ...q, retired: true, retiredAt: Date.now() / 1000 | 0 });
showToast('Question retired', 'success');
} else {
showToast('Question deleted', 'success'); showToast('Question deleted', 'success');
}
notifyStructureBump(data);
renderQuestions();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
@ -1631,12 +1702,15 @@ async function updateOption(q, answerOptionID, fields) {
} }
async function deleteOption(q, answerOptionID) { async function deleteOption(q, answerOptionID) {
if (!confirm('Delete this answer option?')) return; if (!confirm('Remove this answer option?')) return;
try { try {
await apiDelete('answer_options.php', { answerOptionID }); const data = await apiDelete('answer_options.php', { answerOptionID });
if (!data.retired) {
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID); q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
}
renderQuestionBody(q); renderQuestionBody(q);
showToast('Option deleted', 'success'); notifyStructureBump(data);
showToast(data.retired ? 'Option retired' : 'Option deleted', 'success');
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
} }