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

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

View File

@ -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 !== '') {