diff --git a/api/export.php b/api/export.php index 1d0e8fd..d9d9713 100644 --- a/api/export.php +++ b/api/export.php @@ -1,143 +1,10 @@ "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); +$_SERVER['REQUEST_METHOD'] = 'GET'; +$method = 'GET'; +require __DIR__ . '/../handlers/export.php'; diff --git a/db_init.php b/db_init.php index ef81129..fc6cbf0 100644 --- a/db_init.php +++ b/db_init.php @@ -78,6 +78,12 @@ 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 0f70697..de2eff5 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -88,6 +88,14 @@ 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 = []; @@ -152,11 +160,6 @@ 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) { @@ -354,7 +357,7 @@ if ($fetchClients) { 'clientCode' => $code, 'completedQuestionnaires' => $completions[$code] ?? [], ]; - }, $clientCodes); + }, array_values(array_filter($clientCodes, fn($code) => strcasecmp($code, 'DEVELOPER-SETTINGS') !== 0))); qdb_discard($tmpDb, $lockFp); json_success(['coachID' => $coachID, 'clients' => $clients]); diff --git a/handlers/export.php b/handlers/export.php index f4df063..6805ad8 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -1,5 +1,7 @@ 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']; @@ -95,7 +106,14 @@ if (!empty($questionIDs) && !empty($clients)) { 'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null, ]; } - $c['answers'] = $answerMap; + $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; } unset($c); } else { diff --git a/lib/questionnaire_reachability.php b/lib/questionnaire_reachability.php new file mode 100644 index 0000000..483a801 --- /dev/null +++ b/lib/questionnaire_reachability.php @@ -0,0 +1,155 @@ +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 ?? ''; +}