413 lines
14 KiB
PHP
413 lines
14 KiB
PHP
<?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) '
|
|
. qdb_upsert_update(
|
|
['questionnaireID', 'structureRevision'],
|
|
[
|
|
'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;
|
|
}
|
|
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
|
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;
|
|
}
|
|
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
|
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;
|
|
}
|