enhanced questionnaire version handling and handling uploads from outdated questionnaires through automatic revisioning and checks
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
34
common.php
34
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) {
|
||||
@ -1821,6 +1824,7 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
||||
'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]);
|
||||
|
||||
64
db_init.php
64
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 (
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
@ -50,12 +52,14 @@ case 'POST':
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
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]);
|
||||
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();
|
||||
$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]);
|
||||
$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();
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
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) {
|
||||
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
|
||||
@ -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,9 +275,12 @@ 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 (!$isLegacySubmit) {
|
||||
$activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID);
|
||||
$staleQuestionIds = array_diff(
|
||||
array_values($activeMaps['shortIdMap']),
|
||||
array_keys($submittedQuestionIds)
|
||||
);
|
||||
if ($staleQuestionIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?'));
|
||||
$staleDelete = $pdo->prepare(
|
||||
@ -291,10 +288,12 @@ if ($method === 'POST') {
|
||||
);
|
||||
$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);
|
||||
|
||||
@ -578,14 +589,18 @@ if ($qnID) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'meta' => ['id' => $qnID],
|
||||
'structureRevision' => $structureRevision,
|
||||
'questions' => $questions,
|
||||
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
||||
]);
|
||||
}
|
||||
|
||||
// Default: ordered questionnaire list
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$rows = $pdo->query("
|
||||
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey
|
||||
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
|
||||
COALESCE(structureRevision, 1) AS structureRevision
|
||||
FROM questionnaire
|
||||
WHERE state = 'active'
|
||||
ORDER BY orderIndex
|
||||
@ -597,6 +612,7 @@ foreach ($rows as $r) {
|
||||
'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'] ?? '');
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
@ -14,23 +15,34 @@ case 'GET':
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$stmt = $pdo->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);
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/questionnaire_structure.php';
|
||||
|
||||
/**
|
||||
* 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}>
|
||||
*/
|
||||
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<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;
|
||||
}
|
||||
|
||||
414
lib/questionnaire_structure.php
Normal file
414
lib/questionnaire_structure.php
Normal 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;
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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,8 +175,21 @@ function qdb_record_submission_after_submit(
|
||||
':sa' => $startedAt,
|
||||
':ca' => $completedAt,
|
||||
':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(
|
||||
'INSERT INTO client_answer_submission (submissionID, 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]);
|
||||
}
|
||||
|
||||
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,9 +325,22 @@ function qdb_export_all_versions_rows(
|
||||
|
||||
$rows = [];
|
||||
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 = [
|
||||
'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'] ?? ''),
|
||||
@ -218,17 +353,18 @@ function qdb_export_all_versions_rows(
|
||||
'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;
|
||||
}
|
||||
$answerMap = qdb_load_client_answer_map(
|
||||
$pdo,
|
||||
$s['clientCode'],
|
||||
$submissionQuestionIDs,
|
||||
'client_answer_submission',
|
||||
$s['submissionID']
|
||||
);
|
||||
|
||||
foreach ($resultColumns as $col) {
|
||||
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(
|
||||
@ -701,6 +852,7 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
|
||||
$submissions[] = [
|
||||
'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,
|
||||
|
||||
18
schema.sql
18
schema.sql
@ -52,7 +52,18 @@ CREATE TABLE IF NOT EXISTS questionnaire (
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
showPoints INTEGER NOT NULL DEFAULT 0,
|
||||
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 (
|
||||
@ -63,6 +74,8 @@ CREATE TABLE IF NOT EXISTS question (
|
||||
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)
|
||||
);
|
||||
|
||||
@ -154,6 +168,8 @@ CREATE TABLE IF NOT EXISTS questionnaire_submission (
|
||||
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),
|
||||
|
||||
78
tests/Integration/AppSubmitLegacyTest.php
Normal file
78
tests/Integration/AppSubmitLegacyTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
56
tests/Unit/QuestionnaireStructureTest.php
Normal file
56
tests/Unit/QuestionnaireStructureTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
|
||||
@ -467,6 +467,7 @@ function clientQnBlockHTML(clientCode, q) {
|
||||
data-qn="${esc(q.questionnaireID)}"
|
||||
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
|
||||
<strong>Version ${s.version}</strong>
|
||||
${s.structureRevision ? `<span class="badge badge-q">struct rev ${s.structureRevision}</span>` : ''}
|
||||
${s.changedCount > 0
|
||||
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
|
||||
: '<span class="client-qn-summary">Same as live</span>'}
|
||||
|
||||
@ -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() {
|
||||
<label>Version</label>
|
||||
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
|
||||
</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">
|
||||
<label>State</label>
|
||||
<select id="metaState" ${editable ? '' : 'disabled'}>
|
||||
@ -809,6 +820,13 @@ function renderEditor() {
|
||||
</div>
|
||||
<div id="addQuestionFormWrapper" style="display:none"></div>
|
||||
<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 id="tabContent-translations" style="display:none">
|
||||
@ -830,6 +848,7 @@ function renderEditor() {
|
||||
if (!isNew) {
|
||||
initTabs();
|
||||
renderQuestions();
|
||||
renderRetiredQuestions();
|
||||
if (editable) {
|
||||
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
|
||||
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() {
|
||||
const tabBar = document.getElementById('editorTabs');
|
||||
if (!tabBar) return;
|
||||
@ -1181,6 +1215,7 @@ function showAddQuestionForm() {
|
||||
|
||||
questions.push(newQ);
|
||||
renderQuestions();
|
||||
notifyStructureBump(qData);
|
||||
showToast('Question added', 'success');
|
||||
|
||||
document.getElementById('aq_text').value = '';
|
||||
@ -1211,6 +1246,30 @@ function hideAddQuestionForm() {
|
||||
|
||||
// ── 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() {
|
||||
const list = document.getElementById('questionList');
|
||||
if (!list) return;
|
||||
@ -1219,6 +1278,7 @@ function renderQuestions() {
|
||||
// update tab label
|
||||
const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]');
|
||||
if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`;
|
||||
renderRetiredQuestions();
|
||||
|
||||
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>`;
|
||||
@ -1603,12 +1663,23 @@ async function updateQuestion(questionID, fields) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await apiDelete('questions.php', { questionID: q.questionID });
|
||||
const data = await apiDelete('questions.php', { 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');
|
||||
}
|
||||
notifyStructureBump(data);
|
||||
renderQuestions();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
@ -1631,12 +1702,15 @@ async function updateOption(q, answerOptionID, fields) {
|
||||
}
|
||||
|
||||
async function deleteOption(q, answerOptionID) {
|
||||
if (!confirm('Delete this answer option?')) return;
|
||||
if (!confirm('Remove this answer option?')) return;
|
||||
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);
|
||||
}
|
||||
renderQuestionBody(q);
|
||||
showToast('Option deleted', 'success');
|
||||
notifyStructureBump(data);
|
||||
showToast(data.retired ? 'Option retired' : 'Option deleted', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user