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