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 */ 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|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, * shortIdToType: array, * optionMap: array>, * symptomParentMap: array, * freeTextMaxLen: array, * manifestQuestionIds: list * } */ 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 */ 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 */ 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; }