diff --git a/common.php b/common.php index 15e0f8a..96e4487 100644 --- a/common.php +++ b/common.php @@ -1744,6 +1744,7 @@ function qdb_translation_row_label(array $e): string { /** Export one questionnaire with structure + all translations (portable JSON). */ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { require_once __DIR__ . '/lib/scoring.php'; + 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); @@ -1751,10 +1752,11 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { return null; } - $qStmt = $pdo->prepare(" - SELECT questionID, defaultText, type, orderIndex, isRequired, configJson - FROM question WHERE questionnaireID = :id ORDER BY orderIndex - "); + $qStmt = $pdo->prepare( + 'SELECT questionID, defaultText, type, orderIndex, isRequired, configJson + FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . ' + ORDER BY orderIndex' + ); $qStmt->execute([':id' => $qnID]); $questionsOut = []; @@ -1763,10 +1765,11 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; $optionsOut = []; - $aoStmt = $pdo->prepare(" - SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId - FROM answer_option WHERE questionID = :qid ORDER BY orderIndex - "); + $aoStmt = $pdo->prepare( + 'SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId + FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . ' + ORDER BY orderIndex' + ); $aoStmt->execute([':qid' => $dbQ['questionID']]); $qKey = qdb_question_key($config, $dbQ['defaultText']); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { @@ -1814,13 +1817,14 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { return [ 'questionnaire' => [ - 'questionnaireID' => $qnRow['questionnaireID'], - 'name' => $qnRow['name'], - 'version' => $qnRow['version'], - 'state' => $qnRow['state'], - 'orderIndex' => (int)$qnRow['orderIndex'], - 'showPoints' => (int)$qnRow['showPoints'], - 'conditionJson' => $qnRow['conditionJson'] ?: '{}', + 'questionnaireID' => $qnRow['questionnaireID'], + 'name' => $qnRow['name'], + 'version' => $qnRow['version'], + 'state' => $qnRow['state'], + 'orderIndex' => (int)$qnRow['orderIndex'], + 'showPoints' => (int)$qnRow['showPoints'], + 'conditionJson' => $qnRow['conditionJson'] ?: '{}', + 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID), ], 'questions' => $questionsOut, 'stringTranslations' => $stringTranslations, @@ -1855,7 +1859,7 @@ function qdb_export_scoring_profiles_bundle(PDO $pdo): array { 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 { $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); $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 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]); } + 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 completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); $pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); diff --git a/db_init.php b/db_init.php index 7ac22dd..9b0d95e 100644 --- a/db_init.php +++ b/db_init.php @@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) { define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); } define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 11); +define('QDB_VERSION', 12); function qdb_table_exists(PDO $pdo, string $table): bool { $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')) { $pdo->exec(" CREATE TABLE questionnaire_submission ( diff --git a/handlers/answer_options.php b/handlers/answer_options.php index fe8e162..0065a9c 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -1,5 +1,7 @@ prepare('SELECT 1 FROM question WHERE questionID = :id'); + $chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id'); $chk->execute([':id' => $qID]); - if (!$chk->fetch()) { + $qnID = $chk->fetchColumn(); + if (!$qnID) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Question not found', 404); } + $qnID = (string)$qnID; if ($order === 0) { $max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id'); $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)') ->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]); qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); + $newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_added'); qdb_save($tmpDb, $lockFp); - json_success(['answerOption' => [ + json_success([ + 'structureRevision' => $newRev, + 'answerOption' => [ 'answerOptionID' => $id, 'questionID' => $qID, 'optionKey' => $optKey, @@ -115,11 +122,19 @@ case 'PUT': $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $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') ->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]); 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); - json_success(['answerOption' => [ + json_success([ + 'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1), + 'answerOption' => [ 'answerOptionID' => $id, 'questionID' => $row['questionID'], 'optionKey' => $optKey, @@ -144,15 +159,35 @@ case 'DELETE': [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); 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(); + $newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_removed'); + if (qdb_option_has_client_data($pdo, $id)) { + $pdo->prepare('UPDATE answer_option SET retiredAt = :ts WHERE answerOptionID = :id') + ->execute([':ts' => time(), ':id' => $id]); + $pdo->commit(); + qdb_save($tmpDb, $lockFp); + 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('UPDATE client_answer SET answerOptionID = NULL WHERE answerOptionID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]); - $pdo->commit(); $pdo->exec('PRAGMA foreign_keys = ON;'); + $pdo->commit(); qdb_save($tmpDb, $lockFp); - json_success(['deleted' => true]); + json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]); } catch (Throwable $e) { qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index a1b9e5b..afa655f 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -99,13 +99,24 @@ if ($method === 'POST') { $opened = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; - // Verify questionnaire exists - $qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id"); + 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]); - if (!$qnStmt->fetch()) { + $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( @@ -122,42 +133,24 @@ if ($method === 'POST') { ? ($tokenRec['entityID'] ?? '') : ($clientRow['coachID'] ?? ''); - // Load all questions for this questionnaire: full questionID keyed by short ID - $qRows = $pdo->prepare( - "SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn" - ); - $qRows->execute([':qn' => $qnID]); - $shortIdMap = []; // shortId -> fullQuestionID - $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)); + $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; } - // Load all answer options for this questionnaire: keyed by [questionID][defaultText] - $aoRows = $pdo->prepare(" - SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points - FROM answer_option ao - JOIN question q ON q.questionID = ao.questionID - WHERE q.questionnaireID = :qn - "); - $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'], - ]; - } + $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( @@ -167,7 +160,8 @@ if ($method === 'POST') { $shortIdMap, $shortIdToType, $symptomParentMap, - $optionMap + $optionMap, + $submitManifest ); if ($validationErrors !== []) { qdb_discard($tmpDb, $lockFp); @@ -281,20 +275,25 @@ if ($method === 'POST') { $submittedQuestionIds[$parentQID] = true; } - // Re-edit uploads omit answers pruned on the device (e.g. branching changes). - // Drop live rows for questions not present in this payload. - $staleQuestionIds = array_diff(array_values($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)" + if (!$isLegacySubmit) { + $activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID); + $staleQuestionIds = array_diff( + array_values($activeMaps['shortIdMap']), + array_keys($submittedQuestionIds) ); - $staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds))); + 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))); + } } - // Upsert completed_questionnaire record 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(" INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) @@ -323,7 +322,10 @@ if ($method === 'POST') { $startedAt, $completedAt, $sumPoints, - $assignedByCoach + $assignedByCoach, + $submitRev, + $submitManifest, + array_keys($submittedQuestionIds) ); qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID); @@ -331,7 +333,12 @@ if ($method === 'POST') { $pdo->commit(); qdb_save($tmpDb, $lockFp); - json_success(['submitted' => true, 'sumPoints' => $sumPoints]); + json_success([ + 'submitted' => true, + 'sumPoints' => $sumPoints, + 'structureRevision' => $submitRev, + 'legacySubmit' => $isLegacySubmit, + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } @@ -473,6 +480,7 @@ if ($fetchClients) { } 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); @@ -481,10 +489,12 @@ if ($qnID) { json_error('NOT_FOUND', 'Questionnaire not found', 404); } - $stmt = $pdo->prepare(" - SELECT questionID, defaultText, type, orderIndex, configJson - FROM question WHERE questionnaireID = :id ORDER BY orderIndex - "); + $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); @@ -547,10 +557,11 @@ if ($qnID) { } } - $ao = $pdo->prepare(" - SELECT defaultText, points, nextQuestionId - FROM answer_option WHERE questionID = :qid ORDER BY orderIndex - "); + $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); @@ -577,15 +588,19 @@ if ($qnID) { qdb_discard($tmpDb, $lockFp); json_success([ - 'meta' => ['id' => $qnID], - 'questions' => $questions, - 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), + '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 + SELECT questionnaireID, name, showPoints, conditionJson, categoryKey, + COALESCE(structureRevision, 1) AS structureRevision FROM questionnaire WHERE state = 'active' ORDER BY orderIndex @@ -594,10 +609,11 @@ $rows = $pdo->query(" $list = []; foreach ($rows as $r) { $item = [ - 'id' => $r['questionnaireID'], - 'name' => $r['name'], - 'showPoints' => (bool)(int)$r['showPoints'], - 'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(), + '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 !== '') { diff --git a/handlers/questions.php b/handlers/questions.php index 4f76a47..8bebf9d 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -1,6 +1,7 @@ prepare(" - SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson - FROM question WHERE questionnaireID = :qid ORDER BY orderIndex - "); + $includeRetired = !empty($_GET['includeRetired']); + $qWhere = 'questionnaireID = :qid'; + 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]); $questions = $stmt->fetchAll(PDO::FETCH_ASSOC); + $structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID); foreach ($questions as &$q) { $q['isRequired'] = (int)$q['isRequired']; $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']); $q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE); $q['questionKey'] = qdb_question_key($cfg, $q['defaultText']); $q['localId'] = qdb_question_local_id($q['questionID'], $qnID); - $ao = $pdo->prepare(" - SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId - FROM answer_option WHERE questionID = :qid ORDER BY orderIndex - "); + $ao = $pdo->prepare( + "SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId, retiredAt + FROM answer_option WHERE questionID = :qid AND " . qdb_active_options_clause('answer_option') . ' + ORDER BY orderIndex' + ); $ao->execute([':qid' => $q['questionID']]); $q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC); foreach ($q['answerOptions'] as &$opt) { @@ -51,7 +63,10 @@ case 'GET': } unset($q); qdb_discard($tmpDb, $lockFp); - json_success(['questions' => $questions]); + json_success([ + 'questions' => $questions, + 'structureRevision' => $structureRevision, + ]); } catch (Throwable $e) { 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)) { qdb_sync_question_score_rules($pdo, $id, $scoreRules); } + $newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_added'); qdb_save($tmpDb, $lockFp); - json_success(['question' => [ + json_success([ + 'structureRevision' => $newRev, + 'question' => [ 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, 'questionKey' => $questionKey, 'localId' => $localId, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, @@ -129,7 +147,8 @@ case 'POST': ? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [], 'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true) ? qdb_get_score_rules_for_question($pdo, $id) : [], - ]]); + ], + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); } @@ -175,6 +194,17 @@ case 'PUT': $body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null) ); $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") ->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]); 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)) { 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); - json_success(['question' => [ + json_success([ + 'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']), + 'question' => [ 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], 'defaultText' => $text, 'questionKey' => $questionKey, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, @@ -196,7 +232,8 @@ case 'PUT': ? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [], 'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true) ? qdb_get_score_rules_for_question($pdo, $id) : [], - ]]); + ], + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); } @@ -211,22 +248,53 @@ case 'DELETE': $id = $body['questionID']; [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); 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(); - $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]); 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 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 client_answer WHERE questionID = :id")->execute([':id' => $id]); - $pdo->prepare("DELETE FROM question 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_translation 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->exec("PRAGMA foreign_keys = ON;"); qdb_save($tmpDb, $lockFp); - json_success(['deleted' => true]); + json_success([ + 'deleted' => true, + 'retired' => false, + 'structureRevision' => $newRev, + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } diff --git a/lib/app_submit_validate.php b/lib/app_submit_validate.php index 8a4b3e3..84d46d5 100644 --- a/lib/app_submit_validate.php +++ b/lib/app_submit_validate.php @@ -1,8 +1,11 @@ |null $manifest Structure manifest for legacy revision submits * @return list */ function qdb_validate_app_submit_payload( @@ -12,7 +15,8 @@ function qdb_validate_app_submit_payload( array $shortIdMap, array $shortIdToType, array $symptomParentMap, - array $optionMap + array $optionMap, + ?array $manifest = null ): array { $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( - '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]); foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) { @@ -169,3 +178,44 @@ function qdb_validate_app_submit_payload( return $errors; } + +/** + * @return list + */ +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; +} diff --git a/lib/questionnaire_structure.php b/lib/questionnaire_structure.php new file mode 100644 index 0000000..a4e6e18 --- /dev/null +++ b/lib/questionnaire_structure.php @@ -0,0 +1,414 @@ +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 + */ +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|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, + * shortIdToType: array, + * optionMap: array>, + * symptomParentMap: array, + * freeTextMaxLen: array, + * manifestQuestionIds: list + * } + */ +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 + */ +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 + */ +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; +} diff --git a/lib/read_db_cache.php b/lib/read_db_cache.php index 9732acb..09a1759 100644 --- a/lib/read_db_cache.php +++ b/lib/read_db_cache.php @@ -19,6 +19,8 @@ function qdb_read_db_open(): array { } [$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); return [$pdo, QDB_READONLY_CACHE_HANDLE, null]; } diff --git a/lib/submissions.php b/lib/submissions.php index 6f49cf4..79994dd 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -3,6 +3,110 @@ * 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 { $stmt = $pdo->prepare( 'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission @@ -24,11 +128,21 @@ function qdb_record_submission_after_submit( ?int $completedAt, int $sumPoints, string $assignedByCoach, + int $structureRevision = 1, + ?array $structureManifest = null, + ?array $questionIdsToCopy = null, ?int $submittedAt = null ): string { $version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID); $submissionID = bin2hex(random_bytes(16)); $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( '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 ( submissionID, clientCode, questionnaireID, version, submittedAt, submittedByUserID, submittedByRole, assignedByCoach, - status, startedAt, completedAt, sumPoints + status, startedAt, completedAt, sumPoints, + structureRevision, structureSnapshotJson ) VALUES ( :sid, :cc, :qn, :ver, :sat, :uid, :role, :abc, - :st, :sa, :ca, :sp + :st, :sa, :ca, :sp, + :srev, :snap )' )->execute([ ':sid' => $submissionID, @@ -59,17 +175,31 @@ function qdb_record_submission_after_submit( ':sa' => $startedAt, ':ca' => $completedAt, ':sp' => $sumPoints, + ':srev' => max(1, $structureRevision), + ':snap' => $snapshotJson, ]); - $copy = $pdo->prepare( - 'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) - SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt - FROM client_answer - WHERE clientCode = :cc AND questionID IN ( - SELECT questionID FROM question WHERE questionnaireID = :qn - )' - ); - $copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]); + 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( + 'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) + SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt + FROM client_answer + WHERE clientCode = :cc AND questionID IN ( + SELECT questionID FROM question WHERE questionnaireID = :qn + )' + ); + $copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]); + } return $submissionID; } @@ -169,6 +299,7 @@ function qdb_export_all_versions_rows( SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole, qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints, qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus, + qs.structureRevision, qs.structureSnapshotJson, cl.clientCode, cl.coachID, co.username AS coachUsername, sv.username AS supervisorUsername @@ -184,16 +315,7 @@ function qdb_export_all_versions_rows( $stmt->execute($params); $submissions = $stmt->fetchAll(PDO::FETCH_ASSOC); - $questionIDs = 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) - "); + $defaultQuestionIDs = array_column($questions, 'questionID'); $userMap = []; $userStmt = $pdo->query('SELECT userID, username FROM users'); @@ -203,32 +325,46 @@ function qdb_export_all_versions_rows( $rows = []; foreach ($submissions as $s) { - $row = [ - 'submissionID' => $s['submissionID'], - 'version' => (string)(int)$s['version'], - 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', - 'submittedByRole'=> $s['submittedByRole'] ?? '', - 'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''), - 'clientCode' => $s['clientCode'], - 'coach' => $s['coachUsername'] ?? $s['coachID'], - 'supervisor' => $s['supervisorUsername'] ?? '', - 'status' => $s['submissionStatus'] ?? '', - 'sumPoints' => $s['submissionSumPoints'] ?? '', - 'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '', - 'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', - ]; - - $bindParams = array_merge([$s['submissionID']], $questionIDs); - $answerStmt->execute($bindParams); - $answerMap = []; - foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) { - $answerMap[$a['questionID']] = $a; + $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; } - foreach ($resultColumns as $col) { + $row = [ + 'submissionID' => $s['submissionID'], + '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']) : '', + 'submittedByRole' => $s['submittedByRole'] ?? '', + 'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''), + 'clientCode' => $s['clientCode'], + 'coach' => $s['coachUsername'] ?? $s['coachID'], + 'supervisor' => $s['supervisorUsername'] ?? '', + 'status' => $s['submissionStatus'] ?? '', + 'sumPoints' => $s['submissionSumPoints'] ?? '', + 'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '', + 'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', + ]; + + $answerMap = qdb_load_client_answer_map( + $pdo, + $s['clientCode'], + $submissionQuestionIDs, + 'client_answer_submission', + $s['submissionID'] + ); + + foreach ($submissionColumns as $col) { $qid = $col['questionID']; $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; } @@ -671,7 +807,8 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array ); $subStmt = $pdo->prepare( - 'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt + 'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt, + structureRevision, structureSnapshotJson FROM questionnaire_submission WHERE clientCode = :cc AND questionnaireID = :qn 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]); $submissions = []; 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( $pdo, $clientCode, - $questionIDs, + $versionQuestionIDs, 'client_answer_submission', $s['submissionID'] ); $versionAnswers = qdb_answers_display_rows( $pdo, - $resultColumns, + $versionColumns, $subMap, - $optionTextMap, - $stringLabelCache, + $versionOptionMap, + $versionStringCache, $liveMap ); $changedCount = count(array_filter( @@ -699,14 +850,15 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array static fn($r) => !empty($r['changedFromLive']) )); $submissions[] = [ - 'submissionID' => $s['submissionID'], - 'version' => (int)$s['version'], - 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', - 'status' => $s['status'] ?? '', - 'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null, - 'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '', - 'changedCount' => $changedCount, - 'answers' => $versionAnswers, + 'submissionID' => $s['submissionID'], + 'version' => (int)$s['version'], + 'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)), + 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', + 'status' => $s['status'] ?? '', + 'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null, + 'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '', + 'changedCount' => $changedCount, + 'answers' => $versionAnswers, ]; } diff --git a/schema.sql b/schema.sql index 5d082fa..859d397 100644 --- a/schema.sql +++ b/schema.sql @@ -45,24 +45,37 @@ CREATE TABLE IF NOT EXISTS questionnaire_category ( ); CREATE TABLE IF NOT EXISTS questionnaire ( - questionnaireID TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - version TEXT NOT NULL DEFAULT '', - state TEXT NOT NULL DEFAULT '', - orderIndex INTEGER NOT NULL DEFAULT 0, - showPoints INTEGER NOT NULL DEFAULT 0, - conditionJson TEXT NOT NULL DEFAULT '{}', - categoryKey TEXT NOT NULL DEFAULT '' + questionnaireID TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + orderIndex INTEGER NOT NULL DEFAULT 0, + showPoints INTEGER NOT NULL DEFAULT 0, + conditionJson 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 ( - questionID TEXT NOT NULL PRIMARY KEY, - questionnaireID TEXT NOT NULL, - defaultText TEXT NOT NULL DEFAULT '', - type TEXT NOT NULL DEFAULT '', - orderIndex INTEGER NOT NULL DEFAULT 0, - isRequired INTEGER NOT NULL DEFAULT 0, - configJson TEXT NOT NULL DEFAULT '{}', + questionID TEXT NOT NULL PRIMARY KEY, + questionnaireID TEXT NOT NULL, + defaultText TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT '', + orderIndex INTEGER NOT NULL DEFAULT 0, + isRequired INTEGER NOT NULL DEFAULT 0, + configJson TEXT NOT NULL DEFAULT '{}', + retiredAt INTEGER NOT NULL DEFAULT 0, + retiredInRevision INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ); @@ -73,6 +86,7 @@ CREATE TABLE IF NOT EXISTS answer_option ( points INTEGER NOT NULL DEFAULT 0, orderIndex INTEGER NOT NULL DEFAULT 0, nextQuestionId TEXT NOT NULL DEFAULT '', + retiredAt INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(questionID) REFERENCES question(questionID) ); @@ -142,18 +156,20 @@ CREATE TABLE IF NOT EXISTS session ( ); CREATE TABLE IF NOT EXISTS questionnaire_submission ( - submissionID TEXT NOT NULL PRIMARY KEY, - clientCode TEXT NOT NULL, - questionnaireID TEXT NOT NULL, - version INTEGER NOT NULL, - submittedAt INTEGER NOT NULL, - submittedByUserID TEXT NOT NULL DEFAULT '', - submittedByRole TEXT NOT NULL DEFAULT '', - assignedByCoach TEXT, - status TEXT NOT NULL DEFAULT '', - startedAt INTEGER, - completedAt INTEGER, - sumPoints INTEGER, + submissionID TEXT NOT NULL PRIMARY KEY, + clientCode TEXT NOT NULL, + questionnaireID TEXT NOT NULL, + version INTEGER NOT NULL, + submittedAt INTEGER NOT NULL, + submittedByUserID TEXT NOT NULL DEFAULT '', + submittedByRole TEXT NOT NULL DEFAULT '', + assignedByCoach TEXT, + status TEXT NOT NULL DEFAULT '', + startedAt INTEGER, + completedAt INTEGER, + sumPoints INTEGER, + structureRevision INTEGER NOT NULL DEFAULT 1, + structureSnapshotJson TEXT NOT NULL DEFAULT '{}', FOREIGN KEY(clientCode) REFERENCES client(clientCode), FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID), FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID), diff --git a/tests/Integration/AppSubmitLegacyTest.php b/tests/Integration/AppSubmitLegacyTest.php new file mode 100644 index 0000000..3f8eeca --- /dev/null +++ b/tests/Integration/AppSubmitLegacyTest.php @@ -0,0 +1,78 @@ +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); + } +} diff --git a/tests/Support/QdbTestCase.php b/tests/Support/QdbTestCase.php index 35b3088..dfb4629 100644 --- a/tests/Support/QdbTestCase.php +++ b/tests/Support/QdbTestCase.php @@ -137,18 +137,24 @@ abstract class QdbTestCase extends PHPUnitTestCase array $answers, ?int $startedAt = null, ?int $completedAt = null, + ?int $structureRevision = null, ): array { $now = time(); $startedAt ??= $now - 60; $completedAt ??= $now; - return $this->api()->encryptedPost($token, 'app_questionnaires', [ + $payload = [ 'questionnaireID' => $questionnaireId, 'clientCode' => $clientCode, 'answers' => $answers, 'startedAt' => $startedAt, 'completedAt' => $completedAt, - ]); + ]; + if ($structureRevision !== null) { + $payload['structureRevision'] = $structureRevision; + } + + return $this->api()->encryptedPost($token, 'app_questionnaires', $payload); } protected function submissionVersionCount(string $clientCode, string $questionnaireId): int diff --git a/tests/Unit/AppSubmitValidateTest.php b/tests/Unit/AppSubmitValidateTest.php index 7908164..3772bde 100644 --- a/tests/Unit/AppSubmitValidateTest.php +++ b/tests/Unit/AppSubmitValidateTest.php @@ -37,6 +37,28 @@ final class AppSubmitValidateTest extends QdbTestCase $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 { require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php'; diff --git a/tests/Unit/QuestionnaireStructureTest.php b/tests/Unit/QuestionnaireStructureTest.php new file mode 100644 index 0000000..1096494 --- /dev/null +++ b/tests/Unit/QuestionnaireStructureTest.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/website/css/style.css b/website/css/style.css index 5d9f399..67ffb32 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -318,6 +318,18 @@ code { .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-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-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); } diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index e263b72..32887db 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -467,6 +467,7 @@ function clientQnBlockHTML(clientCode, q) { data-qn="${esc(q.questionnaireID)}" data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'} Version ${s.version} + ${s.structureRevision ? `struct rev ${s.structureRevision}` : ''} ${s.changedCount > 0 ? `${s.changedCount} diff vs live` : 'Same as live'} diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 6687bdc..3a9b349 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -43,6 +43,7 @@ const GLASS_SCORE_LEVELS = [ let questionnaire = null; let questions = []; +let retiredQuestions = []; let isNew = false; let activeTab = 'questions'; let allQuestionnaires = []; @@ -86,14 +87,19 @@ export async function editorPage(params) { renderEditor(); } else { 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); if (!questionnaire) { showToast('Questionnaire not found', 'error'); navigate('#/questionnaires'); 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; renderEditor(); } catch (e) { @@ -772,6 +778,11 @@ function renderEditor() { +
+ + +