From e0be2c501f6f2ee53280cd54215b0875f82c2ae5 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 22 May 2026 14:23:39 +0200 Subject: [PATCH] Revert "refactored export functionality" This reverts commit 2616238dc8081ceff56cbd67db247b0b9a2d94ba. --- api/export.php | 145 +++++++++++++++++++++++++-- db_init.php | 6 -- handlers/app_questionnaires.php | 15 ++- handlers/export.php | 22 ++-- handlers/results.php | 20 +--- lib/questionnaire_reachability.php | 155 ----------------------------- 6 files changed, 157 insertions(+), 206 deletions(-) delete mode 100644 lib/questionnaire_reachability.php diff --git a/api/export.php b/api/export.php index d9d9713..1d0e8fd 100644 --- a/api/export.php +++ b/api/export.php @@ -1,10 +1,143 @@ "Method not allowed"]); + exit; +} + +$qnID = $_GET['questionnaireID'] ?? ''; +$format = strtolower($_GET['format'] ?? 'csv'); + +if (!$qnID) { + header('Content-Type: application/json; charset=UTF-8'); + http_response_code(400); + echo json_encode(["error" => "questionnaireID query param required"]); + exit; +} + +[$pdo, $tmpDb, $lockFp] = qdb_open(false); + +// Questionnaire metadata +$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); +$qn->execute([':id' => $qnID]); +$questionnaire = $qn->fetch(PDO::FETCH_ASSOC); +if (!$questionnaire) { + qdb_discard($tmpDb, $lockFp); + header('Content-Type: application/json; charset=UTF-8'); + http_response_code(404); + echo json_encode(["error" => "Questionnaire not found"]); + exit; +} + +// Questions ordered +$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); +$qStmt->execute([':id' => $qnID]); +$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); +$questionIDs = array_column($questions, 'questionID'); + +// Build answer-option lookup for resolving display text +$optionTextMap = []; +foreach ($questionIDs as $qid) { + $aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid"); + $aoStmt->execute([':qid' => $qid]); + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionTextMap[$ao['answerOptionID']] = $ao['defaultText']; + } +} + +// RBAC filter +[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + +$sql = " + SELECT cl.clientCode, cl.coachID, + co.username AS coachUsername, + sv.username AS supervisorUsername, + cq.status, cq.sumPoints, cq.startedAt, cq.completedAt + FROM client cl + INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid + LEFT JOIN coach co ON co.coachID = cl.coachID + LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID + WHERE $rbacClause + ORDER BY cl.clientCode +"; +$params = array_merge([':qnid' => $qnID], $rbacParams); +$cStmt = $pdo->prepare($sql); +$cStmt->execute($params); +$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC); + +// Fetch answers for each client +$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'"; +$answerStmt = $pdo->prepare(" + SELECT questionID, answerOptionID, freeTextValue, numericValue + FROM client_answer + WHERE clientCode = ? AND questionID IN ($qPlaceholders) +"); + +$rows = []; +foreach ($clients as $c) { + $row = [ + 'clientCode' => $c['clientCode'], + 'coach' => $c['coachUsername'] ?? $c['coachID'], + 'supervisor' => $c['supervisorUsername'] ?? '', + 'status' => $c['status'], + 'sumPoints' => $c['sumPoints'], + 'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '', + 'completedAt'=> $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '', + ]; + + $bindParams = array_merge([$c['clientCode']], $questionIDs); + $answerStmt->execute($bindParams); + $answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC); + $answerMap = []; + foreach ($answers as $a) $answerMap[$a['questionID']] = $a; + + foreach ($questions as $q) { + $qid = $q['questionID']; + if (isset($answerMap[$qid])) { + $a = $answerMap[$qid]; + if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) { + $row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']]; + } elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') { + $row[$q['defaultText']] = $a['freeTextValue']; + } elseif ($a['numericValue'] !== null) { + $row[$q['defaultText']] = $a['numericValue']; + } else { + $row[$q['defaultText']] = ''; + } + } else { + $row[$q['defaultText']] = ''; + } + } + $rows[] = $row; +} + +qdb_discard($tmpDb, $lockFp); + +// Output CSV +$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']); +$filename = $safeName . '_v' . $questionnaire['version'] . '.csv'; + +header('Content-Type: text/csv; charset=UTF-8'); +header('Content-Disposition: attachment; filename="' . $filename . '"'); + +$out = fopen('php://output', 'w'); +// BOM for Excel UTF-8 recognition +fwrite($out, "\xEF\xBB\xBF"); + +if (!empty($rows)) { + fputcsv($out, array_keys($rows[0])); + foreach ($rows as $row) { + fputcsv($out, array_values($row)); + } +} else { + $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt']; + foreach ($questions as $q) $header[] = $q['defaultText']; + fputcsv($out, $header); +} +fclose($out); diff --git a/db_init.php b/db_init.php index fc6cbf0..ef81129 100644 --- a/db_init.php +++ b/db_init.php @@ -78,12 +78,6 @@ function qdb_open(bool $writable = false): array { } $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); $pdo->exec("PRAGMA foreign_keys = ON;"); - - // Persist upgrade even when opened read-only (otherwise migration is lost on discard) - if (!$writable) { - qdb_save($tmpDb, $lockFp); - return qdb_open(false); - } } return [$pdo, $tmpDb, $lockFp]; diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index de2eff5..0f70697 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -88,14 +88,6 @@ if ($method === 'POST') { $pdo->beginTransaction(); - // Latest cache = full replace for this questionnaire (drop answers on unreachable branches) - $pdo->prepare(" - DELETE FROM client_answer - WHERE clientCode = :cc AND questionID IN ( - SELECT questionID FROM question WHERE questionnaireID = :qn - ) - ")->execute([':cc' => $clientCode, ':qn' => $qnID]); - $sumPoints = 0; $parsedAnswers = []; @@ -160,6 +152,11 @@ if ($method === 'POST') { $answerInsert = $pdo->prepare(" INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) + ON CONFLICT(clientCode, questionID) DO UPDATE SET + answerOptionID = excluded.answerOptionID, + freeTextValue = excluded.freeTextValue, + numericValue = excluded.numericValue, + answeredAt = excluded.answeredAt "); foreach ($parsedAnswers as $pa) { @@ -357,7 +354,7 @@ if ($fetchClients) { 'clientCode' => $code, 'completedQuestionnaires' => $completions[$code] ?? [], ]; - }, array_values(array_filter($clientCodes, fn($code) => strcasecmp($code, 'DEVELOPER-SETTINGS') !== 0))); + }, $clientCodes); qdb_discard($tmpDb, $lockFp); json_success(['coachID' => $coachID, 'clients' => $clients]); diff --git a/handlers/export.php b/handlers/export.php index 6805ad8..f4df063 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -1,7 +1,5 @@ execute([':id' => $qnID]); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questionIDs = array_column($questions, 'questionID'); -$optionTextMap = []; -foreach ($questionIDs as $qid) { - $aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid"); - $aoStmt->execute([':qid' => $qid]); - foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { - $optionTextMap[$ao['answerOptionID']] = $ao['defaultText']; - } -} - foreach ($questions as &$q) { $q['isRequired'] = (int)$q['isRequired']; $q['orderIndex'] = (int)$q['orderIndex']; @@ -106,14 +95,7 @@ if (!empty($questionIDs) && !empty($clients)) { 'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null, ]; } - $reachable = qrb_reachable_question_ids($pdo, $qnID, $answerMap, $optionTextMap); - $filtered = []; - foreach ($answerMap as $qid => $ans) { - if (isset($reachable[$qid])) { - $filtered[$qid] = $ans; - } - } - $c['answers'] = $filtered; + $c['answers'] = $answerMap; } unset($c); } else { diff --git a/lib/questionnaire_reachability.php b/lib/questionnaire_reachability.php deleted file mode 100644 index 483a801..0000000 --- a/lib/questionnaire_reachability.php +++ /dev/null @@ -1,155 +0,0 @@ -prepare(" - SELECT questionID, defaultText, type, orderIndex, configJson - FROM question - WHERE questionnaireID = :qn - ORDER BY orderIndex - "); - $stmt->execute([':qn' => $questionnaireId]); - $path = []; - - foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $parts = explode('__', $row['questionID']); - $shortId = end($parts); - $config = json_decode($row['configJson'] ?? '{}', true) ?: []; - - $options = []; - $ao = $pdo->prepare(" - SELECT defaultText, nextQuestionId - FROM answer_option - WHERE questionID = :qid - ORDER BY orderIndex - "); - $ao->execute([':qid' => $row['questionID']]); - foreach ($ao->fetchAll(PDO::FETCH_ASSOC) as $opt) { - $options[] = [ - 'text' => $opt['defaultText'], - 'nextQuestionId' => $opt['nextQuestionId'] ?? '', - ]; - } - - $symptoms = []; - if (!empty($config['symptoms']) && is_array($config['symptoms'])) { - $symptoms = $config['symptoms']; - } - - $path[] = [ - 'questionID' => $row['questionID'], - 'shortId' => $shortId, - 'type' => $row['type'] ?? '', - 'storageKey' => $row['defaultText'] !== '' ? $row['defaultText'] : $shortId, - 'options' => $options, - 'symptoms' => $symptoms, - 'isLastPage' => ($row['type'] ?? '') === 'last_page', - ]; - } - - return $path; -} - -/** - * @param array $answerMap keyed by full questionID - * @return array reachable full questionIDs - */ -function qrb_reachable_question_ids(PDO $pdo, string $questionnaireId, array $answerMap, array $optionTextMap): array { - $path = qrb_load_path_questions($pdo, $questionnaireId); - $reachable = []; - $shortToIndex = []; - foreach ($path as $idx => $q) { - $shortToIndex[$q['shortId']] = $idx; - } - - $i = 0; - $count = count($path); - while ($i < $count) { - $q = $path[$i]; - if ($q['isLastPage']) { - $i++; - continue; - } - - $reachable[$q['questionID']] = true; - - $nextTarget = qrb_resolve_next_target($q, $answerMap, $optionTextMap); - if ($nextTarget !== null) { - foreach ($path as $pq) { - if ($pq['isLastPage'] && $pq['shortId'] === $nextTarget) { - break 2; - } - } - if (isset($shortToIndex[$nextTarget])) { - $i = $shortToIndex[$nextTarget]; - } else { - $i++; - } - } elseif (qrb_has_branching_options($q) && !qrb_has_branch_answer($q, $answerMap, $optionTextMap)) { - break; - } else { - $i++; - } - } - - return $reachable; -} - -function qrb_resolve_next_target(array $q, array $answerMap, array $optionTextMap): ?string { - $answer = $answerMap[$q['questionID']] ?? null; - if ($answer === null) { - return null; - } - - $selectedText = qrb_answer_display_value($answer, $optionTextMap); - if ($selectedText === null || $selectedText === '') { - return null; - } - - foreach ($q['options'] as $opt) { - if ($opt['text'] === $selectedText && $opt['nextQuestionId'] !== '') { - return $opt['nextQuestionId']; - } - } - - return null; -} - -function qrb_has_branching_options(array $q): bool { - foreach ($q['options'] as $opt) { - if ($opt['nextQuestionId'] !== '') { - return true; - } - } - return false; -} - -function qrb_has_branch_answer(array $q, array $answerMap, array $optionTextMap): bool { - $answer = $answerMap[$q['questionID']] ?? null; - if ($answer === null) { - return false; - } - $v = qrb_answer_display_value($answer, $optionTextMap); - return $v !== null && $v !== ''; -} - -function qrb_answer_display_value(array $answer, array $optionTextMap): ?string { - if (!empty($answer['answerOptionID']) && isset($optionTextMap[$answer['answerOptionID']])) { - return $optionTextMap[$answer['answerOptionID']]; - } - if (isset($answer['freeTextValue']) && $answer['freeTextValue'] !== '') { - return (string)$answer['freeTextValue']; - } - if (isset($answer['numericValue']) && $answer['numericValue'] !== null && $answer['numericValue'] !== '') { - return (string)$answer['numericValue']; - } - return null; -} - -function qrb_format_cell(array $answer, array $optionTextMap): string { - $v = qrb_answer_display_value($answer, $optionTextMap); - return $v ?? ''; -}