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:
48
common.php
48
common.php
@ -1744,6 +1744,7 @@ function qdb_translation_row_label(array $e): string {
|
|||||||
/** Export one questionnaire with structure + all translations (portable JSON). */
|
/** Export one questionnaire with structure + all translations (portable JSON). */
|
||||||
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
||||||
require_once __DIR__ . '/lib/scoring.php';
|
require_once __DIR__ . '/lib/scoring.php';
|
||||||
|
require_once __DIR__ . '/lib/questionnaire_structure.php';
|
||||||
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
||||||
$qn->execute([':id' => $qnID]);
|
$qn->execute([':id' => $qnID]);
|
||||||
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
||||||
@ -1751,10 +1752,11 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$qStmt = $pdo->prepare("
|
$qStmt = $pdo->prepare(
|
||||||
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
|
'SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
|
||||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
|
||||||
");
|
ORDER BY orderIndex'
|
||||||
|
);
|
||||||
$qStmt->execute([':id' => $qnID]);
|
$qStmt->execute([':id' => $qnID]);
|
||||||
$questionsOut = [];
|
$questionsOut = [];
|
||||||
|
|
||||||
@ -1763,10 +1765,11 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
|||||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||||
|
|
||||||
$optionsOut = [];
|
$optionsOut = [];
|
||||||
$aoStmt = $pdo->prepare("
|
$aoStmt = $pdo->prepare(
|
||||||
SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId
|
'SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId
|
||||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
|
||||||
");
|
ORDER BY orderIndex'
|
||||||
|
);
|
||||||
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
||||||
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
||||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||||
@ -1814,13 +1817,14 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'questionnaire' => [
|
'questionnaire' => [
|
||||||
'questionnaireID' => $qnRow['questionnaireID'],
|
'questionnaireID' => $qnRow['questionnaireID'],
|
||||||
'name' => $qnRow['name'],
|
'name' => $qnRow['name'],
|
||||||
'version' => $qnRow['version'],
|
'version' => $qnRow['version'],
|
||||||
'state' => $qnRow['state'],
|
'state' => $qnRow['state'],
|
||||||
'orderIndex' => (int)$qnRow['orderIndex'],
|
'orderIndex' => (int)$qnRow['orderIndex'],
|
||||||
'showPoints' => (int)$qnRow['showPoints'],
|
'showPoints' => (int)$qnRow['showPoints'],
|
||||||
'conditionJson' => $qnRow['conditionJson'] ?: '{}',
|
'conditionJson' => $qnRow['conditionJson'] ?: '{}',
|
||||||
|
'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID),
|
||||||
],
|
],
|
||||||
'questions' => $questionsOut,
|
'questions' => $questionsOut,
|
||||||
'stringTranslations' => $stringTranslations,
|
'stringTranslations' => $stringTranslations,
|
||||||
@ -1855,7 +1859,7 @@ function qdb_export_scoring_profiles_bundle(PDO $pdo): array {
|
|||||||
return qdb_list_scoring_profiles($pdo);
|
return qdb_list_scoring_profiles($pdo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */
|
/** Remove questionnaire and related rows (full replace import; not per-question retire). */
|
||||||
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
|
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
|
||||||
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
||||||
$qIds->execute([':id' => $qnID]);
|
$qIds->execute([':id' => $qnID]);
|
||||||
@ -1870,8 +1874,20 @@ function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
|
|||||||
}
|
}
|
||||||
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
|
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
|
||||||
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
|
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
|
||||||
|
if (qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||||
|
$pdo->prepare('DELETE FROM client_answer_submission WHERE questionID = :qid')
|
||||||
|
->execute([':qid' => $qid]);
|
||||||
|
}
|
||||||
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
|
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
|
||||||
}
|
}
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) {
|
||||||
|
$pdo->prepare('DELETE FROM questionnaire_structure_snapshot WHERE questionnaireID = :id')
|
||||||
|
->execute([':id' => $qnID]);
|
||||||
|
}
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
$pdo->prepare('DELETE FROM questionnaire_submission WHERE questionnaireID = :id')
|
||||||
|
->execute([':id' => $qnID]);
|
||||||
|
}
|
||||||
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
||||||
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
||||||
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
||||||
|
|||||||
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_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||||
}
|
}
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
define('QDB_VERSION', 11);
|
define('QDB_VERSION', 12);
|
||||||
|
|
||||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
@ -55,6 +55,68 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'questionnaire', 'structureRevision')) {
|
||||||
|
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'questionnaire', 'structureChangedAt')) {
|
||||||
|
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureChangedAt INTEGER NOT NULL DEFAULT 0');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE questionnaire_structure_snapshot (
|
||||||
|
questionnaireID TEXT NOT NULL,
|
||||||
|
structureRevision INTEGER NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
manifestJson TEXT NOT NULL DEFAULT '{}',
|
||||||
|
PRIMARY KEY (questionnaireID, structureRevision),
|
||||||
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'question')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'question', 'retiredAt')) {
|
||||||
|
$pdo->exec('ALTER TABLE question ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'question', 'retiredInRevision')) {
|
||||||
|
$pdo->exec('ALTER TABLE question ADD COLUMN retiredInRevision INTEGER NOT NULL DEFAULT 0');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'answer_option')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'answer_option', 'retiredAt')) {
|
||||||
|
$pdo->exec('ALTER TABLE answer_option ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureRevision')) {
|
||||||
|
$pdo->exec('ALTER TABLE questionnaire_submission ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureSnapshotJson')) {
|
||||||
|
$pdo->exec("ALTER TABLE questionnaire_submission ADD COLUMN structureSnapshotJson TEXT NOT NULL DEFAULT '{}'");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')
|
||||||
|
&& qdb_table_exists($pdo, 'questionnaire')) {
|
||||||
|
require_once __DIR__ . '/lib/questionnaire_structure.php';
|
||||||
|
if (qdb_backfill_structure_snapshots($pdo)) {
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
$pdo->exec("
|
$pdo->exec("
|
||||||
CREATE TABLE questionnaire_submission (
|
CREATE TABLE questionnaire_submission (
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||||
|
|
||||||
$tokenRec = require_valid_token_web();
|
$tokenRec = require_valid_token_web();
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
@ -50,12 +52,14 @@ case 'POST':
|
|||||||
|
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||||
try {
|
try {
|
||||||
$chk = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id');
|
$chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id');
|
||||||
$chk->execute([':id' => $qID]);
|
$chk->execute([':id' => $qID]);
|
||||||
if (!$chk->fetch()) {
|
$qnID = $chk->fetchColumn();
|
||||||
|
if (!$qnID) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_error('NOT_FOUND', 'Question not found', 404);
|
json_error('NOT_FOUND', 'Question not found', 404);
|
||||||
}
|
}
|
||||||
|
$qnID = (string)$qnID;
|
||||||
if ($order === 0) {
|
if ($order === 0) {
|
||||||
$max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id');
|
$max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id');
|
||||||
$max->execute([':id' => $qID]);
|
$max->execute([':id' => $qID]);
|
||||||
@ -65,8 +69,11 @@ case 'POST':
|
|||||||
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
|
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
|
||||||
->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
|
->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
|
||||||
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
|
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
|
||||||
|
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_added');
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['answerOption' => [
|
json_success([
|
||||||
|
'structureRevision' => $newRev,
|
||||||
|
'answerOption' => [
|
||||||
'answerOptionID' => $id,
|
'answerOptionID' => $id,
|
||||||
'questionID' => $qID,
|
'questionID' => $qID,
|
||||||
'optionKey' => $optKey,
|
'optionKey' => $optKey,
|
||||||
@ -115,11 +122,19 @@ case 'PUT':
|
|||||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||||
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
|
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
|
||||||
|
|
||||||
|
$keyChanged = $optKey !== qdb_option_key($row);
|
||||||
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
|
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
|
||||||
->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
|
->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
|
||||||
qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman);
|
qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman);
|
||||||
|
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$row['questionID']);
|
||||||
|
$newRev = null;
|
||||||
|
if ($keyChanged && $qnID !== null) {
|
||||||
|
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_key_changed');
|
||||||
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['answerOption' => [
|
json_success([
|
||||||
|
'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1),
|
||||||
|
'answerOption' => [
|
||||||
'answerOptionID' => $id,
|
'answerOptionID' => $id,
|
||||||
'questionID' => $row['questionID'],
|
'questionID' => $row['questionID'],
|
||||||
'optionKey' => $optKey,
|
'optionKey' => $optKey,
|
||||||
@ -144,15 +159,35 @@ case 'DELETE':
|
|||||||
|
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||||
try {
|
try {
|
||||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
$existing = $pdo->prepare('SELECT questionID FROM answer_option WHERE answerOptionID = :id');
|
||||||
|
$existing->execute([':id' => $id]);
|
||||||
|
$qid = $existing->fetchColumn();
|
||||||
|
if (!$qid) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Answer option not found', 404);
|
||||||
|
}
|
||||||
|
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$qid);
|
||||||
|
if ($qnID === null) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Question not found', 404);
|
||||||
|
}
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
|
$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('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->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
|
||||||
$pdo->commit();
|
|
||||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||||
|
$pdo->commit();
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['deleted' => true]);
|
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
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();
|
$opened = qdb_open_write_or_fail();
|
||||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
|
|
||||||
// Verify questionnaire exists
|
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||||
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
|
|
||||||
|
$qnStmt = $pdo->prepare(
|
||||||
|
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
|
||||||
|
FROM questionnaire WHERE questionnaireID = :id'
|
||||||
|
);
|
||||||
$qnStmt->execute([':id' => $qnID]);
|
$qnStmt->execute([':id' => $qnID]);
|
||||||
if (!$qnStmt->fetch()) {
|
$qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$qnRow) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||||
}
|
}
|
||||||
|
$currentRev = max(1, (int)$qnRow['structureRevision']);
|
||||||
|
$submitRev = max(1, (int)($body['structureRevision'] ?? 1));
|
||||||
|
if ($submitRev > $currentRev) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('STRUCTURE_REVISION_NEWER', 'App structure revision is newer than server', 400);
|
||||||
|
}
|
||||||
|
|
||||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
$clStmt = $pdo->prepare(
|
$clStmt = $pdo->prepare(
|
||||||
@ -122,42 +133,24 @@ if ($method === 'POST') {
|
|||||||
? ($tokenRec['entityID'] ?? '')
|
? ($tokenRec['entityID'] ?? '')
|
||||||
: ($clientRow['coachID'] ?? '');
|
: ($clientRow['coachID'] ?? '');
|
||||||
|
|
||||||
// Load all questions for this questionnaire: full questionID keyed by short ID
|
$isLegacySubmit = $submitRev < $currentRev;
|
||||||
$qRows = $pdo->prepare(
|
if ($isLegacySubmit) {
|
||||||
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn"
|
$submitManifest = qdb_load_structure_manifest($pdo, $qnID, $submitRev);
|
||||||
);
|
if ($submitManifest === null) {
|
||||||
$qRows->execute([':qn' => $qnID]);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
$shortIdMap = []; // shortId -> fullQuestionID
|
json_error('STRUCTURE_REVISION_UNKNOWN', 'Unknown structure revision', 400);
|
||||||
$shortIdToType = []; // shortId -> layout type
|
|
||||||
$freeTextMaxLen = []; // fullQuestionID -> maxLength
|
|
||||||
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
|
|
||||||
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
|
||||||
$fullId = $qRow['questionID'];
|
|
||||||
$parts = explode('__', $fullId);
|
|
||||||
$short = end($parts);
|
|
||||||
$shortIdMap[$short] = $fullId;
|
|
||||||
$shortIdToType[$short] = $qRow['type'] ?? '';
|
|
||||||
if (($qRow['type'] ?? '') === 'free_text') {
|
|
||||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
|
||||||
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$submitManifest = qdb_build_structure_manifest($pdo, $qnID, false);
|
||||||
|
$submitManifest['structureRevision'] = $currentRev;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all answer options for this questionnaire: keyed by [questionID][defaultText]
|
$maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest);
|
||||||
$aoRows = $pdo->prepare("
|
$shortIdMap = $maps['shortIdMap'];
|
||||||
SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points
|
$shortIdToType = $maps['shortIdToType'];
|
||||||
FROM answer_option ao
|
$optionMap = $maps['optionMap'];
|
||||||
JOIN question q ON q.questionID = ao.questionID
|
$symptomParentMap = $maps['symptomParentMap'];
|
||||||
WHERE q.questionnaireID = :qn
|
$freeTextMaxLen = $maps['freeTextMaxLen'];
|
||||||
");
|
|
||||||
$aoRows->execute([':qn' => $qnID]);
|
|
||||||
$optionMap = []; // fullQuestionID -> [defaultText -> {answerOptionID, points}]
|
|
||||||
foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
||||||
$optionMap[$ao['questionID']][$ao['defaultText']] = [
|
|
||||||
'answerOptionID' => $ao['answerOptionID'],
|
|
||||||
'points' => (int)$ao['points'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../lib/app_submit_validate.php';
|
require_once __DIR__ . '/../lib/app_submit_validate.php';
|
||||||
$validationErrors = qdb_validate_app_submit_payload(
|
$validationErrors = qdb_validate_app_submit_payload(
|
||||||
@ -167,7 +160,8 @@ if ($method === 'POST') {
|
|||||||
$shortIdMap,
|
$shortIdMap,
|
||||||
$shortIdToType,
|
$shortIdToType,
|
||||||
$symptomParentMap,
|
$symptomParentMap,
|
||||||
$optionMap
|
$optionMap,
|
||||||
|
$submitManifest
|
||||||
);
|
);
|
||||||
if ($validationErrors !== []) {
|
if ($validationErrors !== []) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
@ -281,20 +275,25 @@ if ($method === 'POST') {
|
|||||||
$submittedQuestionIds[$parentQID] = true;
|
$submittedQuestionIds[$parentQID] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-edit uploads omit answers pruned on the device (e.g. branching changes).
|
if (!$isLegacySubmit) {
|
||||||
// Drop live rows for questions not present in this payload.
|
$activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID);
|
||||||
$staleQuestionIds = array_diff(array_values($shortIdMap), array_keys($submittedQuestionIds));
|
$staleQuestionIds = array_diff(
|
||||||
if ($staleQuestionIds !== []) {
|
array_values($activeMaps['shortIdMap']),
|
||||||
$placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?'));
|
array_keys($submittedQuestionIds)
|
||||||
$staleDelete = $pdo->prepare(
|
|
||||||
"DELETE FROM client_answer WHERE clientCode = ? AND questionID IN ($placeholders)"
|
|
||||||
);
|
);
|
||||||
$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';
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
$sumPoints = qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
|
$sumPoints = $isLegacySubmit
|
||||||
|
? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest)
|
||||||
|
: qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
|
||||||
|
|
||||||
$pdo->prepare("
|
$pdo->prepare("
|
||||||
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
||||||
@ -323,7 +322,10 @@ if ($method === 'POST') {
|
|||||||
$startedAt,
|
$startedAt,
|
||||||
$completedAt,
|
$completedAt,
|
||||||
$sumPoints,
|
$sumPoints,
|
||||||
$assignedByCoach
|
$assignedByCoach,
|
||||||
|
$submitRev,
|
||||||
|
$submitManifest,
|
||||||
|
array_keys($submittedQuestionIds)
|
||||||
);
|
);
|
||||||
|
|
||||||
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
|
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
|
||||||
@ -331,7 +333,12 @@ if ($method === 'POST') {
|
|||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
|
json_success([
|
||||||
|
'submitted' => true,
|
||||||
|
'sumPoints' => $sumPoints,
|
||||||
|
'structureRevision' => $submitRev,
|
||||||
|
'legacySubmit' => $isLegacySubmit,
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
}
|
}
|
||||||
@ -473,6 +480,7 @@ if ($fetchClients) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($qnID) {
|
if ($qnID) {
|
||||||
|
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||||
$qn->execute([':id' => $qnID]);
|
$qn->execute([':id' => $qnID]);
|
||||||
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
||||||
@ -481,10 +489,12 @@ if ($qnID) {
|
|||||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||||
SELECT questionID, defaultText, type, orderIndex, configJson
|
$stmt = $pdo->prepare(
|
||||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
'SELECT questionID, defaultText, type, orderIndex, configJson
|
||||||
");
|
FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
|
||||||
|
ORDER BY orderIndex'
|
||||||
|
);
|
||||||
$stmt->execute([':id' => $qnID]);
|
$stmt->execute([':id' => $qnID]);
|
||||||
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
@ -547,10 +557,11 @@ if ($qnID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$ao = $pdo->prepare("
|
$ao = $pdo->prepare(
|
||||||
SELECT defaultText, points, nextQuestionId
|
'SELECT defaultText, points, nextQuestionId
|
||||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
|
||||||
");
|
ORDER BY orderIndex'
|
||||||
|
);
|
||||||
$ao->execute([':qid' => $dbQ['questionID']]);
|
$ao->execute([':qid' => $dbQ['questionID']]);
|
||||||
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
|
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
@ -577,15 +588,19 @@ if ($qnID) {
|
|||||||
|
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success([
|
json_success([
|
||||||
'meta' => ['id' => $qnID],
|
'meta' => ['id' => $qnID],
|
||||||
'questions' => $questions,
|
'structureRevision' => $structureRevision,
|
||||||
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
'questions' => $questions,
|
||||||
|
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: ordered questionnaire list
|
// Default: ordered questionnaire list
|
||||||
|
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||||
|
|
||||||
$rows = $pdo->query("
|
$rows = $pdo->query("
|
||||||
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey
|
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
|
||||||
|
COALESCE(structureRevision, 1) AS structureRevision
|
||||||
FROM questionnaire
|
FROM questionnaire
|
||||||
WHERE state = 'active'
|
WHERE state = 'active'
|
||||||
ORDER BY orderIndex
|
ORDER BY orderIndex
|
||||||
@ -594,10 +609,11 @@ $rows = $pdo->query("
|
|||||||
$list = [];
|
$list = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$item = [
|
$item = [
|
||||||
'id' => $r['questionnaireID'],
|
'id' => $r['questionnaireID'],
|
||||||
'name' => $r['name'],
|
'name' => $r['name'],
|
||||||
'showPoints' => (bool)(int)$r['showPoints'],
|
'showPoints' => (bool)(int)$r['showPoints'],
|
||||||
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
|
'structureRevision' => max(1, (int)$r['structureRevision']),
|
||||||
|
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
|
||||||
];
|
];
|
||||||
$cat = trim($r['categoryKey'] ?? '');
|
$cat = trim($r['categoryKey'] ?? '');
|
||||||
if ($cat !== '') {
|
if ($cat !== '') {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once __DIR__ . '/../lib/scoring.php';
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||||
|
|
||||||
$tokenRec = require_valid_token_web();
|
$tokenRec = require_valid_token_web();
|
||||||
|
|
||||||
@ -14,23 +15,34 @@ case 'GET':
|
|||||||
try {
|
try {
|
||||||
$opened = qdb_open_read_or_fail();
|
$opened = qdb_open_read_or_fail();
|
||||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
$stmt = $pdo->prepare("
|
$includeRetired = !empty($_GET['includeRetired']);
|
||||||
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
|
$qWhere = 'questionnaireID = :qid';
|
||||||
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
|
if (!$includeRetired) {
|
||||||
");
|
$qWhere .= ' AND ' . qdb_active_questions_clause('question');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson,
|
||||||
|
retiredAt, retiredInRevision
|
||||||
|
FROM question WHERE $qWhere ORDER BY orderIndex"
|
||||||
|
);
|
||||||
$stmt->execute([':qid' => $qnID]);
|
$stmt->execute([':qid' => $qnID]);
|
||||||
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||||
foreach ($questions as &$q) {
|
foreach ($questions as &$q) {
|
||||||
$q['isRequired'] = (int)$q['isRequired'];
|
$q['isRequired'] = (int)$q['isRequired'];
|
||||||
$q['orderIndex'] = (int)$q['orderIndex'];
|
$q['orderIndex'] = (int)$q['orderIndex'];
|
||||||
|
$q['retiredAt'] = (int)($q['retiredAt'] ?? 0);
|
||||||
|
$q['retiredInRevision'] = (int)($q['retiredInRevision'] ?? 0);
|
||||||
|
$q['retired'] = $q['retiredAt'] > 0;
|
||||||
$cfg = qdb_parse_config_json($q['configJson']);
|
$cfg = qdb_parse_config_json($q['configJson']);
|
||||||
$q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
$q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
||||||
$q['questionKey'] = qdb_question_key($cfg, $q['defaultText']);
|
$q['questionKey'] = qdb_question_key($cfg, $q['defaultText']);
|
||||||
$q['localId'] = qdb_question_local_id($q['questionID'], $qnID);
|
$q['localId'] = qdb_question_local_id($q['questionID'], $qnID);
|
||||||
$ao = $pdo->prepare("
|
$ao = $pdo->prepare(
|
||||||
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
|
"SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId, retiredAt
|
||||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
FROM answer_option WHERE questionID = :qid AND " . qdb_active_options_clause('answer_option') . '
|
||||||
");
|
ORDER BY orderIndex'
|
||||||
|
);
|
||||||
$ao->execute([':qid' => $q['questionID']]);
|
$ao->execute([':qid' => $q['questionID']]);
|
||||||
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
|
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||||
foreach ($q['answerOptions'] as &$opt) {
|
foreach ($q['answerOptions'] as &$opt) {
|
||||||
@ -51,7 +63,10 @@ case 'GET':
|
|||||||
}
|
}
|
||||||
unset($q);
|
unset($q);
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success(['questions' => $questions]);
|
json_success([
|
||||||
|
'questions' => $questions,
|
||||||
|
'structureRevision' => $structureRevision,
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
}
|
}
|
||||||
@ -119,8 +134,11 @@ case 'POST':
|
|||||||
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
||||||
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
||||||
}
|
}
|
||||||
|
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_added');
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['question' => [
|
json_success([
|
||||||
|
'structureRevision' => $newRev,
|
||||||
|
'question' => [
|
||||||
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
||||||
'questionKey' => $questionKey, 'localId' => $localId,
|
'questionKey' => $questionKey, 'localId' => $localId,
|
||||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||||
@ -129,7 +147,8 @@ case 'POST':
|
|||||||
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
||||||
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
||||||
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
||||||
]]);
|
],
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
}
|
}
|
||||||
@ -175,6 +194,17 @@ case 'PUT':
|
|||||||
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
|
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
|
||||||
);
|
);
|
||||||
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
||||||
|
$oldKey = qdb_question_key($oldCfg, $row['defaultText']);
|
||||||
|
$oldSymptoms = json_encode($oldCfg['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
|
$newSymptoms = json_encode($config['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
|
$structuralChange = ($type !== (string)$row['type'])
|
||||||
|
|| ($req !== (int)$row['isRequired'])
|
||||||
|
|| ($questionKey !== $oldKey)
|
||||||
|
|| ($newSymptoms !== $oldSymptoms);
|
||||||
|
if ((int)($row['retiredAt'] ?? 0) > 0) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('INVALID_FIELD', 'Cannot edit a retired question', 400);
|
||||||
|
}
|
||||||
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
|
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
|
||||||
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
|
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
|
||||||
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||||
@ -186,8 +216,14 @@ case 'PUT':
|
|||||||
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
||||||
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
||||||
}
|
}
|
||||||
|
$newRev = null;
|
||||||
|
if ($structuralChange) {
|
||||||
|
$newRev = qdb_bump_structure_revision($pdo, (string)$row['questionnaireID'], 'question_updated');
|
||||||
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['question' => [
|
json_success([
|
||||||
|
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
|
||||||
|
'question' => [
|
||||||
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
||||||
'defaultText' => $text, 'questionKey' => $questionKey,
|
'defaultText' => $text, 'questionKey' => $questionKey,
|
||||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||||
@ -196,7 +232,8 @@ case 'PUT':
|
|||||||
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
||||||
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
||||||
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
||||||
]]);
|
],
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
}
|
}
|
||||||
@ -211,22 +248,53 @@ case 'DELETE':
|
|||||||
$id = $body['questionID'];
|
$id = $body['questionID'];
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||||
try {
|
try {
|
||||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
$existing = $pdo->prepare('SELECT questionnaireID, retiredAt FROM question WHERE questionID = :id');
|
||||||
|
$existing->execute([':id' => $id]);
|
||||||
|
$row = $existing->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Question not found', 404);
|
||||||
|
}
|
||||||
|
$qnID = (string)$row['questionnaireID'];
|
||||||
|
if ((int)($row['retiredAt'] ?? 0) > 0) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id");
|
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_removed');
|
||||||
|
|
||||||
|
if (qdb_question_has_client_data($pdo, $id)) {
|
||||||
|
qdb_retire_question($pdo, $id, $newRev);
|
||||||
|
$pdo->commit();
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success([
|
||||||
|
'deleted' => false,
|
||||||
|
'retired' => true,
|
||||||
|
'structureRevision' => $newRev,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||||
|
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :id');
|
||||||
$aoIds->execute([':id' => $id]);
|
$aoIds->execute([':id' => $id]);
|
||||||
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
|
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
|
||||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
|
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $aoid]);
|
||||||
}
|
}
|
||||||
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :id')->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM question_score_rule WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :id')->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id')->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare('DELETE FROM question WHERE questionID = :id')->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['deleted' => true]);
|
json_success([
|
||||||
|
'deleted' => true,
|
||||||
|
'retired' => false,
|
||||||
|
'structureRevision' => $newRev,
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/questionnaire_structure.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Android app questionnaire submit payloads before persisting answers.
|
* Validate Android app questionnaire submit payloads before persisting answers.
|
||||||
*
|
*
|
||||||
|
* @param array<string, mixed>|null $manifest Structure manifest for legacy revision submits
|
||||||
* @return list<array{questionID: string, code: string, message: string}>
|
* @return list<array{questionID: string, code: string, message: string}>
|
||||||
*/
|
*/
|
||||||
function qdb_validate_app_submit_payload(
|
function qdb_validate_app_submit_payload(
|
||||||
@ -12,7 +15,8 @@ function qdb_validate_app_submit_payload(
|
|||||||
array $shortIdMap,
|
array $shortIdMap,
|
||||||
array $shortIdToType,
|
array $shortIdToType,
|
||||||
array $symptomParentMap,
|
array $symptomParentMap,
|
||||||
array $optionMap
|
array $optionMap,
|
||||||
|
?array $manifest = null
|
||||||
): array {
|
): array {
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
@ -124,8 +128,13 @@ function qdb_validate_app_submit_payload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($manifest !== null) {
|
||||||
|
return array_merge($errors, qdb_validate_required_answers_from_manifest($manifest, $answeredShort));
|
||||||
|
}
|
||||||
|
|
||||||
$qStmt = $pdo->prepare(
|
$qStmt = $pdo->prepare(
|
||||||
'SELECT questionID, type, isRequired, configJson FROM question WHERE questionnaireID = :qn'
|
'SELECT questionID, type, isRequired, configJson FROM question
|
||||||
|
WHERE questionnaireID = :qn AND ' . qdb_active_questions_clause('question')
|
||||||
);
|
);
|
||||||
$qStmt->execute([':qn' => $qnID]);
|
$qStmt->execute([':qn' => $qnID]);
|
||||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||||
@ -169,3 +178,44 @@ function qdb_validate_app_submit_payload(
|
|||||||
|
|
||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{questionID: string, code: string, message: string}>
|
||||||
|
*/
|
||||||
|
function qdb_validate_required_answers_from_manifest(array $manifest, array $answeredShort): array {
|
||||||
|
$errors = [];
|
||||||
|
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||||
|
if (!is_array($qEntry) || empty($qEntry['isRequired'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||||
|
$type = (string)($qEntry['type'] ?? '');
|
||||||
|
if ($shortId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($type === 'glass_scale_question') {
|
||||||
|
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||||
|
$sk = trim((string)$symptomKey);
|
||||||
|
if ($sk === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (empty($answeredShort[$sk])) {
|
||||||
|
$errors[] = [
|
||||||
|
'questionID' => $sk,
|
||||||
|
'code' => 'MISSING_ANSWER',
|
||||||
|
'message' => 'Required symptom is not answered',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (empty($answeredShort[$shortId])) {
|
||||||
|
$errors[] = [
|
||||||
|
'questionID' => $shortId,
|
||||||
|
'code' => 'MISSING_ANSWER',
|
||||||
|
'message' => 'Required question is not answered',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|||||||
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();
|
[$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db();
|
||||||
|
// Materialize may persist a migration (updates QDB_PATH mtime); refresh signature before caching.
|
||||||
|
$signature = qdb_read_cache_signature();
|
||||||
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
|
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
|
||||||
return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
|
return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,110 @@
|
|||||||
* Questionnaire submission versioning (archive each coach upload).
|
* Questionnaire submission versioning (archive each coach upload).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/questionnaire_structure.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build display columns from a stored structure manifest (historical submissions).
|
||||||
|
*
|
||||||
|
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
|
||||||
|
*/
|
||||||
|
function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
|
||||||
|
$stringLabelCache = [];
|
||||||
|
$resultColumns = [];
|
||||||
|
$questions = [];
|
||||||
|
$optionTextMap = [];
|
||||||
|
|
||||||
|
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||||
|
if (!is_array($qEntry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||||
|
if ($fullId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qStmt = $pdo->prepare(
|
||||||
|
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionID = :id'
|
||||||
|
);
|
||||||
|
$qStmt->execute([':id' => $fullId]);
|
||||||
|
$qRow = $qStmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$qRow) {
|
||||||
|
$symptoms = $qEntry['symptoms'] ?? [];
|
||||||
|
$qRow = [
|
||||||
|
'questionID' => $fullId,
|
||||||
|
'defaultText' => (string)($qEntry['questionKey'] ?? $qEntry['shortId'] ?? $fullId),
|
||||||
|
'type' => (string)($qEntry['type'] ?? ''),
|
||||||
|
'orderIndex' => 0,
|
||||||
|
'configJson' => json_encode(
|
||||||
|
['symptoms' => is_array($symptoms) ? $symptoms : []],
|
||||||
|
JSON_UNESCAPED_UNICODE
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$questions[] = $qRow;
|
||||||
|
|
||||||
|
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||||
|
$type = $qRow['type'] ?? '';
|
||||||
|
if ($type === 'glass_scale_question') {
|
||||||
|
$symptoms = $cfg['symptoms'] ?? $qEntry['symptoms'] ?? [];
|
||||||
|
if (is_array($symptoms) && $symptoms !== []) {
|
||||||
|
foreach ($symptoms as $symptomKey) {
|
||||||
|
$sk = trim((string)$symptomKey);
|
||||||
|
if ($sk === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resultColumns[] = [
|
||||||
|
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
|
||||||
|
'questionID' => $fullId,
|
||||||
|
'symptomKey' => $sk,
|
||||||
|
'kind' => 'glass_symptom',
|
||||||
|
'question' => $qRow,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$resultColumns[] = [
|
||||||
|
'header' => qdb_question_german_label($pdo, $qRow),
|
||||||
|
'questionID' => $fullId,
|
||||||
|
'symptomKey' => null,
|
||||||
|
'kind' => 'question',
|
||||||
|
'question' => $qRow,
|
||||||
|
];
|
||||||
|
|
||||||
|
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
||||||
|
$aoStmt->execute([':qid' => $fullId]);
|
||||||
|
$dbOptions = $aoStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if ($dbOptions !== []) {
|
||||||
|
foreach ($dbOptions as $ao) {
|
||||||
|
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
|
||||||
|
$pdo,
|
||||||
|
$ao['answerOptionID'],
|
||||||
|
$ao['defaultText']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($qEntry['options'] ?? [] as $opt) {
|
||||||
|
if (!is_array($opt)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = (string)($opt['key'] ?? '');
|
||||||
|
if ($key !== '') {
|
||||||
|
$optionTextMap[$key] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'questions' => $questions,
|
||||||
|
'resultColumns' => $resultColumns,
|
||||||
|
'optionTextMap' => $optionTextMap,
|
||||||
|
'stringLabelCache' => $stringLabelCache,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
|
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
|
||||||
@ -24,11 +128,21 @@ function qdb_record_submission_after_submit(
|
|||||||
?int $completedAt,
|
?int $completedAt,
|
||||||
int $sumPoints,
|
int $sumPoints,
|
||||||
string $assignedByCoach,
|
string $assignedByCoach,
|
||||||
|
int $structureRevision = 1,
|
||||||
|
?array $structureManifest = null,
|
||||||
|
?array $questionIdsToCopy = null,
|
||||||
?int $submittedAt = null
|
?int $submittedAt = null
|
||||||
): string {
|
): string {
|
||||||
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
|
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
|
||||||
$submissionID = bin2hex(random_bytes(16));
|
$submissionID = bin2hex(random_bytes(16));
|
||||||
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
|
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
|
||||||
|
$snapshotJson = '{}';
|
||||||
|
if ($structureManifest !== null) {
|
||||||
|
$encoded = json_encode($structureManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded !== false) {
|
||||||
|
$snapshotJson = $encoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$cq = $pdo->prepare(
|
$cq = $pdo->prepare(
|
||||||
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||||
@ -40,11 +154,13 @@ function qdb_record_submission_after_submit(
|
|||||||
'INSERT INTO questionnaire_submission (
|
'INSERT INTO questionnaire_submission (
|
||||||
submissionID, clientCode, questionnaireID, version, submittedAt,
|
submissionID, clientCode, questionnaireID, version, submittedAt,
|
||||||
submittedByUserID, submittedByRole, assignedByCoach,
|
submittedByUserID, submittedByRole, assignedByCoach,
|
||||||
status, startedAt, completedAt, sumPoints
|
status, startedAt, completedAt, sumPoints,
|
||||||
|
structureRevision, structureSnapshotJson
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:sid, :cc, :qn, :ver, :sat,
|
:sid, :cc, :qn, :ver, :sat,
|
||||||
:uid, :role, :abc,
|
:uid, :role, :abc,
|
||||||
:st, :sa, :ca, :sp
|
:st, :sa, :ca, :sp,
|
||||||
|
:srev, :snap
|
||||||
)'
|
)'
|
||||||
)->execute([
|
)->execute([
|
||||||
':sid' => $submissionID,
|
':sid' => $submissionID,
|
||||||
@ -59,17 +175,31 @@ function qdb_record_submission_after_submit(
|
|||||||
':sa' => $startedAt,
|
':sa' => $startedAt,
|
||||||
':ca' => $completedAt,
|
':ca' => $completedAt,
|
||||||
':sp' => $sumPoints,
|
':sp' => $sumPoints,
|
||||||
|
':srev' => max(1, $structureRevision),
|
||||||
|
':snap' => $snapshotJson,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$copy = $pdo->prepare(
|
if ($questionIdsToCopy !== null && $questionIdsToCopy !== []) {
|
||||||
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
$questionIdsToCopy = array_values(array_unique(array_filter($questionIdsToCopy, 'is_string')));
|
||||||
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
$ph = implode(',', array_fill(0, count($questionIdsToCopy), '?'));
|
||||||
FROM client_answer
|
$copy = $pdo->prepare(
|
||||||
WHERE clientCode = :cc AND questionID IN (
|
"INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||||
SELECT questionID FROM question WHERE questionnaireID = :qn
|
SELECT ?, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||||
)'
|
FROM client_answer
|
||||||
);
|
WHERE clientCode = ? AND questionID IN ($ph)"
|
||||||
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
|
);
|
||||||
|
$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;
|
return $submissionID;
|
||||||
}
|
}
|
||||||
@ -169,6 +299,7 @@ function qdb_export_all_versions_rows(
|
|||||||
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
|
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
|
||||||
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
|
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
|
||||||
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
|
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
|
||||||
|
qs.structureRevision, qs.structureSnapshotJson,
|
||||||
cl.clientCode, cl.coachID,
|
cl.clientCode, cl.coachID,
|
||||||
co.username AS coachUsername,
|
co.username AS coachUsername,
|
||||||
sv.username AS supervisorUsername
|
sv.username AS supervisorUsername
|
||||||
@ -184,16 +315,7 @@ function qdb_export_all_versions_rows(
|
|||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
$questionIDs = array_column($questions, 'questionID');
|
$defaultQuestionIDs = array_column($questions, 'questionID');
|
||||||
$qPlaceholders = !empty($questionIDs)
|
|
||||||
? implode(',', array_fill(0, count($questionIDs), '?'))
|
|
||||||
: "'__none__'";
|
|
||||||
|
|
||||||
$answerStmt = $pdo->prepare("
|
|
||||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
|
||||||
FROM client_answer_submission
|
|
||||||
WHERE submissionID = ? AND questionID IN ($qPlaceholders)
|
|
||||||
");
|
|
||||||
|
|
||||||
$userMap = [];
|
$userMap = [];
|
||||||
$userStmt = $pdo->query('SELECT userID, username FROM users');
|
$userStmt = $pdo->query('SELECT userID, username FROM users');
|
||||||
@ -203,32 +325,46 @@ function qdb_export_all_versions_rows(
|
|||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($submissions as $s) {
|
foreach ($submissions as $s) {
|
||||||
$row = [
|
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
|
||||||
'submissionID' => $s['submissionID'],
|
if (!empty($snap['questions'])) {
|
||||||
'version' => (string)(int)$s['version'],
|
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
|
||||||
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
$submissionColumns = $snapCtx['resultColumns'];
|
||||||
'submittedByRole'=> $s['submittedByRole'] ?? '',
|
$submissionOptionMap = $snapCtx['optionTextMap'];
|
||||||
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
|
$submissionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
|
||||||
'clientCode' => $s['clientCode'],
|
} else {
|
||||||
'coach' => $s['coachUsername'] ?? $s['coachID'],
|
$submissionColumns = $resultColumns;
|
||||||
'supervisor' => $s['supervisorUsername'] ?? '',
|
$submissionOptionMap = $optionTextMap;
|
||||||
'status' => $s['submissionStatus'] ?? '',
|
$submissionQuestionIDs = $defaultQuestionIDs;
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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'];
|
$qid = $col['questionID'];
|
||||||
$a = $answerMap[$qid] ?? null;
|
$a = $answerMap[$qid] ?? null;
|
||||||
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $submissionOptionMap);
|
||||||
}
|
}
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
@ -671,7 +807,8 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
|
|||||||
);
|
);
|
||||||
|
|
||||||
$subStmt = $pdo->prepare(
|
$subStmt = $pdo->prepare(
|
||||||
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt
|
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt,
|
||||||
|
structureRevision, structureSnapshotJson
|
||||||
FROM questionnaire_submission
|
FROM questionnaire_submission
|
||||||
WHERE clientCode = :cc AND questionnaireID = :qn
|
WHERE clientCode = :cc AND questionnaireID = :qn
|
||||||
ORDER BY version DESC'
|
ORDER BY version DESC'
|
||||||
@ -679,19 +816,33 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
|
|||||||
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||||
$submissions = [];
|
$submissions = [];
|
||||||
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
|
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
|
||||||
|
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
|
||||||
|
if (!empty($snap['questions'])) {
|
||||||
|
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
|
||||||
|
$versionColumns = $snapCtx['resultColumns'];
|
||||||
|
$versionOptionMap = $snapCtx['optionTextMap'];
|
||||||
|
$versionStringCache = $snapCtx['stringLabelCache'];
|
||||||
|
$versionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
|
||||||
|
} else {
|
||||||
|
$versionColumns = $resultColumns;
|
||||||
|
$versionOptionMap = $optionTextMap;
|
||||||
|
$versionStringCache = $stringLabelCache;
|
||||||
|
$versionQuestionIDs = $questionIDs;
|
||||||
|
}
|
||||||
|
|
||||||
$subMap = qdb_load_client_answer_map(
|
$subMap = qdb_load_client_answer_map(
|
||||||
$pdo,
|
$pdo,
|
||||||
$clientCode,
|
$clientCode,
|
||||||
$questionIDs,
|
$versionQuestionIDs,
|
||||||
'client_answer_submission',
|
'client_answer_submission',
|
||||||
$s['submissionID']
|
$s['submissionID']
|
||||||
);
|
);
|
||||||
$versionAnswers = qdb_answers_display_rows(
|
$versionAnswers = qdb_answers_display_rows(
|
||||||
$pdo,
|
$pdo,
|
||||||
$resultColumns,
|
$versionColumns,
|
||||||
$subMap,
|
$subMap,
|
||||||
$optionTextMap,
|
$versionOptionMap,
|
||||||
$stringLabelCache,
|
$versionStringCache,
|
||||||
$liveMap
|
$liveMap
|
||||||
);
|
);
|
||||||
$changedCount = count(array_filter(
|
$changedCount = count(array_filter(
|
||||||
@ -699,14 +850,15 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
|
|||||||
static fn($r) => !empty($r['changedFromLive'])
|
static fn($r) => !empty($r['changedFromLive'])
|
||||||
));
|
));
|
||||||
$submissions[] = [
|
$submissions[] = [
|
||||||
'submissionID' => $s['submissionID'],
|
'submissionID' => $s['submissionID'],
|
||||||
'version' => (int)$s['version'],
|
'version' => (int)$s['version'],
|
||||||
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
|
||||||
'status' => $s['status'] ?? '',
|
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
||||||
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
|
'status' => $s['status'] ?? '',
|
||||||
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
|
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
|
||||||
'changedCount' => $changedCount,
|
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
|
||||||
'answers' => $versionAnswers,
|
'changedCount' => $changedCount,
|
||||||
|
'answers' => $versionAnswers,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
schema.sql
70
schema.sql
@ -45,24 +45,37 @@ CREATE TABLE IF NOT EXISTS questionnaire_category (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS questionnaire (
|
CREATE TABLE IF NOT EXISTS questionnaire (
|
||||||
questionnaireID TEXT NOT NULL PRIMARY KEY,
|
questionnaireID TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
version TEXT NOT NULL DEFAULT '',
|
version TEXT NOT NULL DEFAULT '',
|
||||||
state TEXT NOT NULL DEFAULT '',
|
state TEXT NOT NULL DEFAULT '',
|
||||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||||
showPoints INTEGER NOT NULL DEFAULT 0,
|
showPoints INTEGER NOT NULL DEFAULT 0,
|
||||||
conditionJson TEXT NOT NULL DEFAULT '{}',
|
conditionJson TEXT NOT NULL DEFAULT '{}',
|
||||||
categoryKey TEXT NOT NULL DEFAULT ''
|
categoryKey TEXT NOT NULL DEFAULT '',
|
||||||
|
structureRevision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
structureChangedAt INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS questionnaire_structure_snapshot (
|
||||||
|
questionnaireID TEXT NOT NULL,
|
||||||
|
structureRevision INTEGER NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
manifestJson TEXT NOT NULL DEFAULT '{}',
|
||||||
|
PRIMARY KEY (questionnaireID, structureRevision),
|
||||||
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS question (
|
CREATE TABLE IF NOT EXISTS question (
|
||||||
questionID TEXT NOT NULL PRIMARY KEY,
|
questionID TEXT NOT NULL PRIMARY KEY,
|
||||||
questionnaireID TEXT NOT NULL,
|
questionnaireID TEXT NOT NULL,
|
||||||
defaultText TEXT NOT NULL DEFAULT '',
|
defaultText TEXT NOT NULL DEFAULT '',
|
||||||
type TEXT NOT NULL DEFAULT '',
|
type TEXT NOT NULL DEFAULT '',
|
||||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||||
isRequired INTEGER NOT NULL DEFAULT 0,
|
isRequired INTEGER NOT NULL DEFAULT 0,
|
||||||
configJson TEXT NOT NULL DEFAULT '{}',
|
configJson TEXT NOT NULL DEFAULT '{}',
|
||||||
|
retiredAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retiredInRevision INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -73,6 +86,7 @@ CREATE TABLE IF NOT EXISTS answer_option (
|
|||||||
points INTEGER NOT NULL DEFAULT 0,
|
points INTEGER NOT NULL DEFAULT 0,
|
||||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||||
nextQuestionId TEXT NOT NULL DEFAULT '',
|
nextQuestionId TEXT NOT NULL DEFAULT '',
|
||||||
|
retiredAt INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(questionID) REFERENCES question(questionID)
|
FOREIGN KEY(questionID) REFERENCES question(questionID)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -142,18 +156,20 @@ CREATE TABLE IF NOT EXISTS session (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS questionnaire_submission (
|
CREATE TABLE IF NOT EXISTS questionnaire_submission (
|
||||||
submissionID TEXT NOT NULL PRIMARY KEY,
|
submissionID TEXT NOT NULL PRIMARY KEY,
|
||||||
clientCode TEXT NOT NULL,
|
clientCode TEXT NOT NULL,
|
||||||
questionnaireID TEXT NOT NULL,
|
questionnaireID TEXT NOT NULL,
|
||||||
version INTEGER NOT NULL,
|
version INTEGER NOT NULL,
|
||||||
submittedAt INTEGER NOT NULL,
|
submittedAt INTEGER NOT NULL,
|
||||||
submittedByUserID TEXT NOT NULL DEFAULT '',
|
submittedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
submittedByRole TEXT NOT NULL DEFAULT '',
|
submittedByRole TEXT NOT NULL DEFAULT '',
|
||||||
assignedByCoach TEXT,
|
assignedByCoach TEXT,
|
||||||
status TEXT NOT NULL DEFAULT '',
|
status TEXT NOT NULL DEFAULT '',
|
||||||
startedAt INTEGER,
|
startedAt INTEGER,
|
||||||
completedAt INTEGER,
|
completedAt INTEGER,
|
||||||
sumPoints INTEGER,
|
sumPoints INTEGER,
|
||||||
|
structureRevision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
structureSnapshotJson TEXT NOT NULL DEFAULT '{}',
|
||||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||||
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
||||||
|
|||||||
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,
|
array $answers,
|
||||||
?int $startedAt = null,
|
?int $startedAt = null,
|
||||||
?int $completedAt = null,
|
?int $completedAt = null,
|
||||||
|
?int $structureRevision = null,
|
||||||
): array {
|
): array {
|
||||||
$now = time();
|
$now = time();
|
||||||
$startedAt ??= $now - 60;
|
$startedAt ??= $now - 60;
|
||||||
$completedAt ??= $now;
|
$completedAt ??= $now;
|
||||||
|
|
||||||
return $this->api()->encryptedPost($token, 'app_questionnaires', [
|
$payload = [
|
||||||
'questionnaireID' => $questionnaireId,
|
'questionnaireID' => $questionnaireId,
|
||||||
'clientCode' => $clientCode,
|
'clientCode' => $clientCode,
|
||||||
'answers' => $answers,
|
'answers' => $answers,
|
||||||
'startedAt' => $startedAt,
|
'startedAt' => $startedAt,
|
||||||
'completedAt' => $completedAt,
|
'completedAt' => $completedAt,
|
||||||
]);
|
];
|
||||||
|
if ($structureRevision !== null) {
|
||||||
|
$payload['structureRevision'] = $structureRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->api()->encryptedPost($token, 'app_questionnaires', $payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int
|
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int
|
||||||
|
|||||||
@ -37,6 +37,28 @@ final class AppSubmitValidateTest extends QdbTestCase
|
|||||||
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
|
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testUnknownQuestionAllowedInLegacyManifest(): void
|
||||||
|
{
|
||||||
|
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$f = $this->fixture();
|
||||||
|
$manifest = qdb_build_structure_manifest($pdo, $f->questionnaireId, true);
|
||||||
|
$maps = qdb_submit_maps_from_manifest($pdo, $f->questionnaireId, $manifest);
|
||||||
|
$errors = qdb_validate_app_submit_payload(
|
||||||
|
$pdo,
|
||||||
|
$f->questionnaireId,
|
||||||
|
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
|
||||||
|
$maps['shortIdMap'],
|
||||||
|
$maps['shortIdToType'],
|
||||||
|
$maps['symptomParentMap'],
|
||||||
|
$maps['optionMap'],
|
||||||
|
$manifest
|
||||||
|
);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
$this->assertSame([], $errors);
|
||||||
|
}
|
||||||
|
|
||||||
public function testValidSingleChoiceWithOptionKeyPasses(): void
|
public function testValidSingleChoiceWithOptionKeyPasses(): void
|
||||||
{
|
{
|
||||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||||
|
|||||||
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-draft { background: var(--badge-draft-bg); color: var(--badge-draft-fg); }
|
||||||
.badge-active { background: var(--badge-active-bg); color: var(--badge-active-fg); }
|
.badge-active { background: var(--badge-active-bg); color: var(--badge-active-fg); }
|
||||||
.badge-archived { background: var(--badge-archived-bg); color: var(--badge-archived-fg); }
|
.badge-archived { background: var(--badge-archived-bg); color: var(--badge-archived-fg); }
|
||||||
|
|
||||||
|
.retired-questions-panel summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.retired-question-item {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.retired-question-item .question-header {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); }
|
.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); }
|
||||||
.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
|
.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
|
||||||
.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); }
|
.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); }
|
||||||
|
|||||||
@ -467,6 +467,7 @@ function clientQnBlockHTML(clientCode, q) {
|
|||||||
data-qn="${esc(q.questionnaireID)}"
|
data-qn="${esc(q.questionnaireID)}"
|
||||||
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
|
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
|
||||||
<strong>Version ${s.version}</strong>
|
<strong>Version ${s.version}</strong>
|
||||||
|
${s.structureRevision ? `<span class="badge badge-q">struct rev ${s.structureRevision}</span>` : ''}
|
||||||
${s.changedCount > 0
|
${s.changedCount > 0
|
||||||
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
|
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
|
||||||
: '<span class="client-qn-summary">Same as live</span>'}
|
: '<span class="client-qn-summary">Same as live</span>'}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ const GLASS_SCORE_LEVELS = [
|
|||||||
|
|
||||||
let questionnaire = null;
|
let questionnaire = null;
|
||||||
let questions = [];
|
let questions = [];
|
||||||
|
let retiredQuestions = [];
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
let activeTab = 'questions';
|
let activeTab = 'questions';
|
||||||
let allQuestionnaires = [];
|
let allQuestionnaires = [];
|
||||||
@ -86,14 +87,19 @@ export async function editorPage(params) {
|
|||||||
renderEditor();
|
renderEditor();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const quData = await apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`);
|
const quData = await apiGet(
|
||||||
|
`questions.php?questionnaireID=${encodeURIComponent(params.id)}&includeRetired=1`
|
||||||
|
);
|
||||||
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
|
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
|
||||||
if (!questionnaire) {
|
if (!questionnaire) {
|
||||||
showToast('Questionnaire not found', 'error');
|
showToast('Questionnaire not found', 'error');
|
||||||
navigate('#/questionnaires');
|
navigate('#/questionnaires');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
questions = quData.questions || [];
|
questionnaire.structureRevision = quData.structureRevision ?? questionnaire.structureRevision ?? 1;
|
||||||
|
const allQs = quData.questions || [];
|
||||||
|
questions = allQs.filter(q => !q.retired);
|
||||||
|
retiredQuestions = allQs.filter(q => q.retired);
|
||||||
document.getElementById('editorTitle').textContent = questionnaire.name;
|
document.getElementById('editorTitle').textContent = questionnaire.name;
|
||||||
renderEditor();
|
renderEditor();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -772,6 +778,11 @@ function renderEditor() {
|
|||||||
<label>Version</label>
|
<label>Version</label>
|
||||||
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
|
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Structure revision</label>
|
||||||
|
<input type="text" value="${esc(String(questionnaire.structureRevision ?? 1))}" disabled
|
||||||
|
title="Auto-incremented when questions or options change structurally">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>State</label>
|
<label>State</label>
|
||||||
<select id="metaState" ${editable ? '' : 'disabled'}>
|
<select id="metaState" ${editable ? '' : 'disabled'}>
|
||||||
@ -809,6 +820,13 @@ function renderEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="addQuestionFormWrapper" style="display:none"></div>
|
<div id="addQuestionFormWrapper" style="display:none"></div>
|
||||||
<ul class="question-list" id="questionList"></ul>
|
<ul class="question-list" id="questionList"></ul>
|
||||||
|
<div id="retiredQuestionsSection" style="display:none;margin-top:20px">
|
||||||
|
<details class="retired-questions-panel">
|
||||||
|
<summary>Retired questions (<span id="retiredCount">0</span>)</summary>
|
||||||
|
<p class="field-hint">Retired questions stay in upload history; counselors on older forms can still submit.</p>
|
||||||
|
<ul class="question-list retired-question-list" id="retiredQuestionList"></ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tabContent-translations" style="display:none">
|
<div id="tabContent-translations" style="display:none">
|
||||||
@ -830,6 +848,7 @@ function renderEditor() {
|
|||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
initTabs();
|
initTabs();
|
||||||
renderQuestions();
|
renderQuestions();
|
||||||
|
renderRetiredQuestions();
|
||||||
if (editable) {
|
if (editable) {
|
||||||
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
|
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
|
||||||
initDragReorder();
|
initDragReorder();
|
||||||
@ -837,6 +856,21 @@ function renderEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyStructureRevision(rev) {
|
||||||
|
if (rev == null) return;
|
||||||
|
questionnaire.structureRevision = rev;
|
||||||
|
const input = document.querySelector('.meta-form input[disabled][title*="Auto-incremented"]');
|
||||||
|
if (input) input.value = String(rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyStructureBump(data) {
|
||||||
|
const rev = data?.structureRevision;
|
||||||
|
if (rev != null) {
|
||||||
|
applyStructureRevision(rev);
|
||||||
|
showToast(`Structure revision bumped to ${rev}.`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initTabs() {
|
function initTabs() {
|
||||||
const tabBar = document.getElementById('editorTabs');
|
const tabBar = document.getElementById('editorTabs');
|
||||||
if (!tabBar) return;
|
if (!tabBar) return;
|
||||||
@ -1181,6 +1215,7 @@ function showAddQuestionForm() {
|
|||||||
|
|
||||||
questions.push(newQ);
|
questions.push(newQ);
|
||||||
renderQuestions();
|
renderQuestions();
|
||||||
|
notifyStructureBump(qData);
|
||||||
showToast('Question added', 'success');
|
showToast('Question added', 'success');
|
||||||
|
|
||||||
document.getElementById('aq_text').value = '';
|
document.getElementById('aq_text').value = '';
|
||||||
@ -1211,6 +1246,30 @@ function hideAddQuestionForm() {
|
|||||||
|
|
||||||
// ── Questions list ───────────────────────────────────────────────────────
|
// ── Questions list ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderRetiredQuestions() {
|
||||||
|
const section = document.getElementById('retiredQuestionsSection');
|
||||||
|
const list = document.getElementById('retiredQuestionList');
|
||||||
|
const countEl = document.getElementById('retiredCount');
|
||||||
|
if (!section || !list) return;
|
||||||
|
if (!retiredQuestions.length) {
|
||||||
|
section.style.display = 'none';
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (countEl) countEl.textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
section.style.display = '';
|
||||||
|
if (countEl) countEl.textContent = String(retiredQuestions.length);
|
||||||
|
list.innerHTML = retiredQuestions.map((q, idx) => `
|
||||||
|
<li class="question-item retired-question-item">
|
||||||
|
<div class="question-header">
|
||||||
|
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}</span>
|
||||||
|
<span class="badge badge-archived">Retired</span>
|
||||||
|
${q.retiredInRevision ? `<span class="badge">rev ${q.retiredInRevision}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function renderQuestions() {
|
function renderQuestions() {
|
||||||
const list = document.getElementById('questionList');
|
const list = document.getElementById('questionList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@ -1219,6 +1278,7 @@ function renderQuestions() {
|
|||||||
// update tab label
|
// update tab label
|
||||||
const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]');
|
const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]');
|
||||||
if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`;
|
if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`;
|
||||||
|
renderRetiredQuestions();
|
||||||
|
|
||||||
if (!questions.length) {
|
if (!questions.length) {
|
||||||
list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`;
|
list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`;
|
||||||
@ -1603,12 +1663,23 @@ async function updateQuestion(questionID, fields) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteQuestion(q) {
|
async function deleteQuestion(q) {
|
||||||
if (!confirm(`Delete "${q.defaultText}" and all its answers?`)) return;
|
if (q.retired) {
|
||||||
|
showToast('This question is already retired', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
try {
|
try {
|
||||||
await apiDelete('questions.php', { questionID: q.questionID });
|
const data = await apiDelete('questions.php', { questionID: q.questionID });
|
||||||
questions = questions.filter(x => x.questionID !== q.questionID);
|
questions = questions.filter(x => x.questionID !== q.questionID);
|
||||||
|
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();
|
renderQuestions();
|
||||||
showToast('Question deleted', 'success');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
}
|
}
|
||||||
@ -1631,12 +1702,15 @@ async function updateOption(q, answerOptionID, fields) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteOption(q, answerOptionID) {
|
async function deleteOption(q, answerOptionID) {
|
||||||
if (!confirm('Delete this answer option?')) return;
|
if (!confirm('Remove this answer option?')) return;
|
||||||
try {
|
try {
|
||||||
await apiDelete('answer_options.php', { answerOptionID });
|
const data = await apiDelete('answer_options.php', { answerOptionID });
|
||||||
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
|
if (!data.retired) {
|
||||||
|
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
|
||||||
|
}
|
||||||
renderQuestionBody(q);
|
renderQuestionBody(q);
|
||||||
showToast('Option deleted', 'success');
|
notifyStructureBump(data);
|
||||||
|
showToast(data.retired ? 'Option retired' : 'Option deleted', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user