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

@ -3,6 +3,110 @@
* Questionnaire submission versioning (archive each coach upload).
*/
require_once __DIR__ . '/questionnaire_structure.php';
/**
* Build display columns from a stored structure manifest (historical submissions).
*
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
*/
function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
$stringLabelCache = [];
$resultColumns = [];
$questions = [];
$optionTextMap = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
if ($fullId === '') {
continue;
}
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionID = :id'
);
$qStmt->execute([':id' => $fullId]);
$qRow = $qStmt->fetch(PDO::FETCH_ASSOC);
if (!$qRow) {
$symptoms = $qEntry['symptoms'] ?? [];
$qRow = [
'questionID' => $fullId,
'defaultText' => (string)($qEntry['questionKey'] ?? $qEntry['shortId'] ?? $fullId),
'type' => (string)($qEntry['type'] ?? ''),
'orderIndex' => 0,
'configJson' => json_encode(
['symptoms' => is_array($symptoms) ? $symptoms : []],
JSON_UNESCAPED_UNICODE
),
];
}
$questions[] = $qRow;
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$type = $qRow['type'] ?? '';
if ($type === 'glass_scale_question') {
$symptoms = $cfg['symptoms'] ?? $qEntry['symptoms'] ?? [];
if (is_array($symptoms) && $symptoms !== []) {
foreach ($symptoms as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
$resultColumns[] = [
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
'questionID' => $fullId,
'symptomKey' => $sk,
'kind' => 'glass_symptom',
'question' => $qRow,
];
}
continue;
}
}
$resultColumns[] = [
'header' => qdb_question_german_label($pdo, $qRow),
'questionID' => $fullId,
'symptomKey' => null,
'kind' => 'question',
'question' => $qRow,
];
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
$aoStmt->execute([':qid' => $fullId]);
$dbOptions = $aoStmt->fetchAll(PDO::FETCH_ASSOC);
if ($dbOptions !== []) {
foreach ($dbOptions as $ao) {
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
$pdo,
$ao['answerOptionID'],
$ao['defaultText']
);
}
} else {
foreach ($qEntry['options'] ?? [] as $opt) {
if (!is_array($opt)) {
continue;
}
$key = (string)($opt['key'] ?? '');
if ($key !== '') {
$optionTextMap[$key] = $key;
}
}
}
}
return [
'questions' => $questions,
'resultColumns' => $resultColumns,
'optionTextMap' => $optionTextMap,
'stringLabelCache' => $stringLabelCache,
];
}
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
$stmt = $pdo->prepare(
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
@ -24,11 +128,21 @@ function qdb_record_submission_after_submit(
?int $completedAt,
int $sumPoints,
string $assignedByCoach,
int $structureRevision = 1,
?array $structureManifest = null,
?array $questionIdsToCopy = null,
?int $submittedAt = null
): string {
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
$submissionID = bin2hex(random_bytes(16));
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
$snapshotJson = '{}';
if ($structureManifest !== null) {
$encoded = json_encode($structureManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded !== false) {
$snapshotJson = $encoded;
}
}
$cq = $pdo->prepare(
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
@ -40,11 +154,13 @@ function qdb_record_submission_after_submit(
'INSERT INTO questionnaire_submission (
submissionID, clientCode, questionnaireID, version, submittedAt,
submittedByUserID, submittedByRole, assignedByCoach,
status, startedAt, completedAt, sumPoints
status, startedAt, completedAt, sumPoints,
structureRevision, structureSnapshotJson
) VALUES (
:sid, :cc, :qn, :ver, :sat,
:uid, :role, :abc,
:st, :sa, :ca, :sp
:st, :sa, :ca, :sp,
:srev, :snap
)'
)->execute([
':sid' => $submissionID,
@ -59,17 +175,31 @@ function qdb_record_submission_after_submit(
':sa' => $startedAt,
':ca' => $completedAt,
':sp' => $sumPoints,
':srev' => max(1, $structureRevision),
':snap' => $snapshotJson,
]);
$copy = $pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = :cc AND questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :qn
)'
);
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
if ($questionIdsToCopy !== null && $questionIdsToCopy !== []) {
$questionIdsToCopy = array_values(array_unique(array_filter($questionIdsToCopy, 'is_string')));
$ph = implode(',', array_fill(0, count($questionIdsToCopy), '?'));
$copy = $pdo->prepare(
"INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT ?, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($ph)"
);
$copy->execute(array_merge([$submissionID, $clientCode], $questionIdsToCopy));
} else {
$copy = $pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = :cc AND questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :qn
)'
);
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
}
return $submissionID;
}
@ -169,6 +299,7 @@ function qdb_export_all_versions_rows(
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
qs.structureRevision, qs.structureSnapshotJson,
cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername
@ -184,16 +315,7 @@ function qdb_export_all_versions_rows(
$stmt->execute($params);
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
$qPlaceholders = !empty($questionIDs)
? implode(',', array_fill(0, count($questionIDs), '?'))
: "'__none__'";
$answerStmt = $pdo->prepare("
SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer_submission
WHERE submissionID = ? AND questionID IN ($qPlaceholders)
");
$defaultQuestionIDs = array_column($questions, 'questionID');
$userMap = [];
$userStmt = $pdo->query('SELECT userID, username FROM users');
@ -203,32 +325,46 @@ function qdb_export_all_versions_rows(
$rows = [];
foreach ($submissions as $s) {
$row = [
'submissionID' => $s['submissionID'],
'version' => (string)(int)$s['version'],
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'submittedByRole'=> $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
'clientCode' => $s['clientCode'],
'coach' => $s['coachUsername'] ?? $s['coachID'],
'supervisor' => $s['supervisorUsername'] ?? '',
'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] ?? '',
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
];
$bindParams = array_merge([$s['submissionID']], $questionIDs);
$answerStmt->execute($bindParams);
$answerMap = [];
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
$answerMap[$a['questionID']] = $a;
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$submissionColumns = $snapCtx['resultColumns'];
$submissionOptionMap = $snapCtx['optionTextMap'];
$submissionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$submissionColumns = $resultColumns;
$submissionOptionMap = $optionTextMap;
$submissionQuestionIDs = $defaultQuestionIDs;
}
foreach ($resultColumns as $col) {
$row = [
'submissionID' => $s['submissionID'],
'version' => (string)(int)$s['version'],
'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'submittedByRole' => $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
'clientCode' => $s['clientCode'],
'coach' => $s['coachUsername'] ?? $s['coachID'],
'supervisor' => $s['supervisorUsername'] ?? '',
'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] ?? '',
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
];
$answerMap = qdb_load_client_answer_map(
$pdo,
$s['clientCode'],
$submissionQuestionIDs,
'client_answer_submission',
$s['submissionID']
);
foreach ($submissionColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $submissionOptionMap);
}
$rows[] = $row;
}
@ -671,7 +807,8 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
);
$subStmt = $pdo->prepare(
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt,
structureRevision, structureSnapshotJson
FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn
ORDER BY version DESC'
@ -679,19 +816,33 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$submissions = [];
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$versionColumns = $snapCtx['resultColumns'];
$versionOptionMap = $snapCtx['optionTextMap'];
$versionStringCache = $snapCtx['stringLabelCache'];
$versionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$versionColumns = $resultColumns;
$versionOptionMap = $optionTextMap;
$versionStringCache = $stringLabelCache;
$versionQuestionIDs = $questionIDs;
}
$subMap = qdb_load_client_answer_map(
$pdo,
$clientCode,
$questionIDs,
$versionQuestionIDs,
'client_answer_submission',
$s['submissionID']
);
$versionAnswers = qdb_answers_display_rows(
$pdo,
$resultColumns,
$versionColumns,
$subMap,
$optionTextMap,
$stringLabelCache,
$versionOptionMap,
$versionStringCache,
$liveMap
);
$changedCount = count(array_filter(
@ -699,14 +850,15 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
static fn($r) => !empty($r['changedFromLive'])
));
$submissions[] = [
'submissionID' => $s['submissionID'],
'version' => (int)$s['version'],
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'status' => $s['status'] ?? '',
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
'changedCount' => $changedCount,
'answers' => $versionAnswers,
'submissionID' => $s['submissionID'],
'version' => (int)$s['version'],
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'status' => $s['status'] ?? '',
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
'changedCount' => $changedCount,
'answers' => $versionAnswers,
];
}