Revert "refactored export functionality"

This reverts commit 2616238dc8.
This commit is contained in:
2026-05-22 14:23:39 +02:00
parent 2616238dc8
commit e0be2c501f
6 changed files with 157 additions and 206 deletions

View File

@ -1,10 +1,143 @@
<?php <?php
/**
* Legacy direct export endpoint (website Export page). Delegates to shared handler.
*/
require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php'; require_once __DIR__ . '/../db_init.php';
$_SERVER['REQUEST_METHOD'] = 'GET'; $tokenRec = require_valid_token();
$method = 'GET';
require __DIR__ . '/../handlers/export.php'; if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
header('Content-Type: application/json; charset=UTF-8');
http_response_code(405);
echo json_encode(["error" => "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);

View File

@ -78,12 +78,6 @@ function qdb_open(bool $writable = false): array {
} }
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
$pdo->exec("PRAGMA foreign_keys = ON;"); $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]; return [$pdo, $tmpDb, $lockFp];

View File

@ -88,14 +88,6 @@ if ($method === 'POST') {
$pdo->beginTransaction(); $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; $sumPoints = 0;
$parsedAnswers = []; $parsedAnswers = [];
@ -160,6 +152,11 @@ if ($method === 'POST') {
$answerInsert = $pdo->prepare(" $answerInsert = $pdo->prepare("
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) 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) { foreach ($parsedAnswers as $pa) {
@ -357,7 +354,7 @@ if ($fetchClients) {
'clientCode' => $code, 'clientCode' => $code,
'completedQuestionnaires' => $completions[$code] ?? [], 'completedQuestionnaires' => $completions[$code] ?? [],
]; ];
}, array_values(array_filter($clientCodes, fn($code) => strcasecmp($code, 'DEVELOPER-SETTINGS') !== 0))); }, $clientCodes);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['coachID' => $coachID, 'clients' => $clients]); json_success(['coachID' => $coachID, 'clients' => $clients]);

View File

@ -1,7 +1,5 @@
<?php <?php
require_once __DIR__ . '/../lib/questionnaire_reachability.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
if ($method !== 'GET') { if ($method !== 'GET') {
@ -81,19 +79,21 @@ foreach ($clients as $c) {
$answerMap = []; $answerMap = [];
foreach ($answers as $a) $answerMap[$a['questionID']] = $a; foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
$reachable = qrb_reachable_question_ids($pdo, $qnID, $answerMap, $optionTextMap);
foreach ($questions as $q) { foreach ($questions as $q) {
$qid = $q['questionID']; $qid = $q['questionID'];
$header = $q['defaultText'];
if (!isset($reachable[$qid])) {
$row[$header] = '';
continue;
}
if (isset($answerMap[$qid])) { if (isset($answerMap[$qid])) {
$row[$header] = qrb_format_cell($answerMap[$qid], $optionTextMap); $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 { } else {
$row[$header] = ''; $row[$q['defaultText']] = '';
} }
} }
$rows[] = $row; $rows[] = $row;

View File

@ -1,7 +1,5 @@
<?php <?php
require_once __DIR__ . '/../lib/questionnaire_reachability.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
if ($method !== 'GET') { if ($method !== 'GET') {
@ -33,15 +31,6 @@ $qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID'); $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) { foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired']; $q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex']; $q['orderIndex'] = (int)$q['orderIndex'];
@ -106,14 +95,7 @@ if (!empty($questionIDs) && !empty($clients)) {
'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null, 'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null,
]; ];
} }
$reachable = qrb_reachable_question_ids($pdo, $qnID, $answerMap, $optionTextMap); $c['answers'] = $answerMap;
$filtered = [];
foreach ($answerMap as $qid => $ans) {
if (isset($reachable[$qid])) {
$filtered[$qid] = $ans;
}
}
$c['answers'] = $filtered;
} }
unset($c); unset($c);
} else { } else {

View File

@ -1,155 +0,0 @@
<?php
/**
* Branching walk for questionnaire exports/results (matches app all-in-one logic).
* Only questions on the active path from current answers are "reachable".
*/
function qrb_load_path_questions(PDO $pdo, string $questionnaireId): array {
$stmt = $pdo->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<string, array> $answerMap keyed by full questionID
* @return array<string, bool> 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 ?? '';
}