Files
barometer-server/lib/submissions.php
2026-07-01 09:26:18 +02:00

1118 lines
43 KiB
PHP

<?php
/**
* Questionnaire submission versioning (archive each coach upload).
*/
require_once __DIR__ . '/questionnaire_structure.php';
/**
* Build display columns from a stored structure manifest (historical submissions).
*
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
*/
function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
$stringLabelCache = [];
$resultColumns = [];
$questions = [];
$optionTextMap = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
if ($fullId === '') {
continue;
}
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionID = :id'
);
$qStmt->execute([':id' => $fullId]);
$qRow = $qStmt->fetch(PDO::FETCH_ASSOC);
if (!$qRow) {
$symptoms = $qEntry['symptoms'] ?? [];
$qRow = [
'questionID' => $fullId,
'defaultText' => (string)($qEntry['questionKey'] ?? $qEntry['shortId'] ?? $fullId),
'type' => (string)($qEntry['type'] ?? ''),
'orderIndex' => 0,
'configJson' => json_encode(
['symptoms' => is_array($symptoms) ? $symptoms : []],
JSON_UNESCAPED_UNICODE
),
];
}
$questions[] = $qRow;
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$type = $qRow['type'] ?? '';
if ($type === 'glass_scale_question') {
$symptoms = $cfg['symptoms'] ?? $qEntry['symptoms'] ?? [];
if (is_array($symptoms) && $symptoms !== []) {
foreach ($symptoms as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
$resultColumns[] = [
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
'questionID' => $fullId,
'symptomKey' => $sk,
'kind' => 'glass_symptom',
'question' => $qRow,
];
}
continue;
}
}
$resultColumns[] = [
'header' => qdb_question_german_label($pdo, $qRow),
'questionID' => $fullId,
'symptomKey' => null,
'kind' => 'question',
'question' => $qRow,
];
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
$aoStmt->execute([':qid' => $fullId]);
$dbOptions = $aoStmt->fetchAll(PDO::FETCH_ASSOC);
if ($dbOptions !== []) {
foreach ($dbOptions as $ao) {
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
$pdo,
$ao['answerOptionID'],
$ao['defaultText']
);
}
} else {
foreach ($qEntry['options'] ?? [] as $opt) {
if (!is_array($opt)) {
continue;
}
$key = (string)($opt['key'] ?? '');
if ($key !== '') {
$optionTextMap[$key] = $key;
}
}
}
}
return [
'questions' => $questions,
'resultColumns' => $resultColumns,
'optionTextMap' => $optionTextMap,
'stringLabelCache' => $stringLabelCache,
];
}
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
$stmt = $pdo->prepare(
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn'
);
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
return (int)$stmt->fetchColumn();
}
/**
* Snapshot live answers + completion metadata after a successful submit.
*/
function qdb_record_submission_after_submit(
PDO $pdo,
string $clientCode,
string $questionnaireID,
array $tokenRec,
?int $startedAt,
?int $completedAt,
int $sumPoints,
string $assignedByCoach,
int $structureRevision = 1,
?array $structureManifest = null,
?array $questionIdsToCopy = null,
?int $submittedAt = null,
?int $programCycleNumber = null,
?string $programSessionID = null,
?int $programSessionNumber = null
): string {
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
$submissionID = bin2hex(random_bytes(16));
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
$snapshotJson = '{}';
if ($structureManifest !== null) {
$encoded = json_encode($structureManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded !== false) {
$snapshotJson = $encoded;
}
}
$cq = $pdo->prepare(
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
);
$cq->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
$status = $cq->fetchColumn() ?: 'completed';
$pdo->prepare(
'INSERT INTO questionnaire_submission (
submissionID, clientCode, questionnaireID, version, submittedAt,
submittedByUserID, submittedByRole, assignedByCoach,
status, startedAt, completedAt, sumPoints,
programCycleNumber, programSessionID, programSessionNumber,
structureRevision, structureSnapshotJson
) VALUES (
:sid, :cc, :qn, :ver, :sat,
:uid, :role, :abc,
:st, :sa, :ca, :sp,
:pcn, :psid, :psn,
:srev, :snap
)'
)->execute([
':sid' => $submissionID,
':cc' => $clientCode,
':qn' => $questionnaireID,
':ver' => $version,
':sat' => $submittedAtValue,
':uid' => $tokenRec['userID'] ?? '',
':role' => $tokenRec['role'] ?? '',
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
':st' => $status,
':sa' => $startedAt,
':ca' => $completedAt,
':sp' => $sumPoints,
':pcn' => $programCycleNumber,
':psid' => $programSessionID,
':psn' => $programSessionNumber,
':srev' => max(1, $structureRevision),
':snap' => $snapshotJson,
]);
if ($questionIdsToCopy !== null && $questionIdsToCopy !== []) {
$questionIdsToCopy = array_values(array_unique(array_filter($questionIdsToCopy, 'is_string')));
$ph = implode(',', array_fill(0, count($questionIdsToCopy), '?'));
$copy = $pdo->prepare(
"INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT ?, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($ph)"
);
$copy->execute(array_merge([$submissionID, $clientCode], $questionIdsToCopy));
} else {
$copy = $pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = :cc AND questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :qn
)'
);
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
}
return $submissionID;
}
/**
* One-time: create version 1 submissions for existing completions without archive rows.
*/
function qdb_backfill_submissions_from_live(PDO $pdo): bool {
$count = (int)$pdo->query('SELECT COUNT(*) FROM questionnaire_submission')->fetchColumn();
if ($count > 0) {
return false;
}
$rows = $pdo->query(
'SELECT clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints
FROM completed_questionnaire'
)->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
return false;
}
$pdo->beginTransaction();
try {
foreach ($rows as $cq) {
$clientCode = $cq['clientCode'];
$qnID = $cq['questionnaireID'];
$chk = $pdo->prepare(
'SELECT 1 FROM client_answer ca
JOIN question q ON q.questionID = ca.questionID
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn LIMIT 1'
);
$chk->execute([':cc' => $clientCode, ':qn' => $qnID]);
if (!$chk->fetchColumn()) {
continue;
}
$submissionID = bin2hex(random_bytes(16));
$completedAt = $cq['completedAt'] ?? time();
$pdo->prepare(
'INSERT INTO questionnaire_submission (
submissionID, clientCode, questionnaireID, version, submittedAt,
submittedByUserID, submittedByRole, assignedByCoach,
status, startedAt, completedAt, sumPoints
) VALUES (
:sid, :cc, :qn, 1, :sat,
\'\', \'\', :abc,
:st, :sa, :ca, :sp
)'
)->execute([
':sid' => $submissionID,
':cc' => $clientCode,
':qn' => $qnID,
':sat' => $completedAt,
':abc' => $cq['assignedByCoach'],
':st' => $cq['status'] ?: 'completed',
':sa' => $cq['startedAt'],
':ca' => $cq['completedAt'],
':sp' => $cq['sumPoints'],
]);
$pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = :cc AND questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :qn
)'
)->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $qnID]);
}
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/**
* RBAC-scoped submission rows for one questionnaire (client, then version).
*
* @return list<array<string, mixed>>
*/
function qdb_fetch_questionnaire_submissions_scoped(
PDO $pdo,
string $questionnaireID,
array $tokenRec
): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
qs.structureRevision, qs.structureSnapshotJson,
qs.programCycleNumber, qs.programSessionID, qs.programSessionNumber,
cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE qs.questionnaireID = :qnid AND ($rbacClause)
ORDER BY cl.clientCode ASC, qs.version ASC
";
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/** @return array<string, string> */
function qdb_submission_submitter_username_map(PDO $pdo): array {
$userMap = [];
$userStmt = $pdo->query('SELECT userID, username FROM users');
foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) {
$userMap[$u['userID']] = $u['username'];
}
return $userMap;
}
/**
* Resolve per-submission question columns from structure snapshot or questionnaire defaults.
*
* @return array{submissionColumns: array, submissionOptionMap: array, submissionQuestionIDs: list<string>}
*/
function qdb_submission_display_context(
PDO $pdo,
array $submissionRow,
array $defaultQuestions,
array $defaultResultColumns,
array $defaultOptionTextMap
): array {
$snap = json_decode($submissionRow['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
return [
'submissionColumns' => $snapCtx['resultColumns'],
'submissionOptionMap' => $snapCtx['optionTextMap'],
'submissionQuestionIDs' => array_column($snapCtx['questions'], 'questionID'),
];
}
return [
'submissionColumns' => $defaultResultColumns,
'submissionOptionMap' => $defaultOptionTextMap,
'submissionQuestionIDs' => array_column($defaultQuestions, 'questionID'),
];
}
/**
* Build CSV rows for all submission versions of one questionnaire (RBAC-scoped).
*
* @return list<array<string, string>>
*/
function qdb_export_all_versions_rows(
PDO $pdo,
string $questionnaireID,
array $questions,
array $resultColumns,
array $optionTextMap,
array $tokenRec
): array {
$submissions = qdb_fetch_questionnaire_submissions_scoped($pdo, $questionnaireID, $tokenRec);
$userMap = qdb_submission_submitter_username_map($pdo);
$rows = [];
foreach ($submissions as $s) {
$ctx = qdb_submission_display_context($pdo, $s, $questions, $resultColumns, $optionTextMap);
$row = [
'clientCode' => $s['clientCode'],
'version' => (string)(int)$s['version'],
'programCycleNumber' => $s['programCycleNumber'] !== null ? (string)(int)$s['programCycleNumber'] : '',
'programSessionNumber' => $s['programSessionNumber'] !== null ? (string)(int)$s['programSessionNumber'] : '',
'programSessionID' => $s['programSessionID'] ?? '',
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'coach' => $s['coachUsername'] ?? $s['coachID'],
'supervisor' => $s['supervisorUsername'] ?? '',
'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] ?? '',
'submissionID' => $s['submissionID'],
'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)),
'submittedByRole' => $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
];
$answerMap = qdb_load_client_answer_map(
$pdo,
$s['clientCode'],
$ctx['submissionQuestionIDs'],
'client_answer_submission',
$s['submissionID']
);
foreach ($ctx['submissionColumns'] as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $ctx['submissionOptionMap']);
}
$rows[] = $row;
}
return $rows;
}
/**
* Submission rows for the results API (all versions, grouped by client in sort order).
*
* @return list<array<string, mixed>>
*/
function qdb_results_submission_entries(
PDO $pdo,
string $questionnaireID,
array $questions,
array $resultColumns,
array $optionTextMap,
array $tokenRec
): array {
$submissions = qdb_fetch_questionnaire_submissions_scoped($pdo, $questionnaireID, $tokenRec);
$userMap = qdb_submission_submitter_username_map($pdo);
$entries = [];
foreach ($submissions as $s) {
$ctx = qdb_submission_display_context($pdo, $s, $questions, $resultColumns, $optionTextMap);
$answerMap = qdb_load_client_answer_map(
$pdo,
$s['clientCode'],
$ctx['submissionQuestionIDs'],
'client_answer_submission',
$s['submissionID']
);
$answers = [];
foreach ($answerMap as $qid => $a) {
$answers[$qid] = [
'answerOptionID' => $a['answerOptionID'],
'freeTextValue' => $a['freeTextValue'],
'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null,
'answeredAt' => null,
];
}
$entries[] = [
'submissionID' => $s['submissionID'],
'version' => (int)$s['version'],
'clientCode' => $s['clientCode'],
'coachID' => $s['coachID'],
'coachUsername' => $s['coachUsername'],
'supervisorUsername' => $s['supervisorUsername'],
'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] !== null ? (int)$s['submissionSumPoints'] : null,
'startedAt' => $s['submissionStartedAt'] !== null ? (int)$s['submissionStartedAt'] : null,
'completedAt' => $s['submissionCompletedAt'] !== null ? (int)$s['submissionCompletedAt'] : null,
'submittedAt' => $s['submittedAt'] !== null ? (int)$s['submittedAt'] : null,
'programCycleNumber' => $s['programCycleNumber'] !== null ? (int)$s['programCycleNumber'] : null,
'programSessionID' => $s['programSessionID'],
'programSessionNumber' => $s['programSessionNumber'] !== null ? (int)$s['programSessionNumber'] : null,
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
'submittedByRole' => $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
'answers' => $answers,
];
}
return $entries;
}
/**
* @return array{questions: array, resultColumns: array, optionTextMap: array}
*/
function qdb_export_questionnaire_context(PDO $pdo, string $questionnaireID): array {
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex'
);
$qStmt->execute([':id' => $questionnaireID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
$resultColumns = qdb_results_export_columns($questions, $questionnaireID);
$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'];
}
}
return [
'questions' => $questions,
'resultColumns' => $resultColumns,
'optionTextMap' => $optionTextMap,
];
}
/**
* Current (live) response rows for one questionnaire, RBAC-scoped.
*
* @return list<array<string, string>>
*/
function qdb_export_current_rows(
PDO $pdo,
string $questionnaireID,
array $questions,
array $resultColumns,
array $optionTextMap,
array $tokenRec
): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$questionIDs = array_column($questions, 'questionID');
$sql = "
SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt,
cq.programCycleNumber, cq.programSessionID, cq.programSessionNumber
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' => $questionnaireID], $rbacParams);
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
$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'],
'programCycleNumber' => $c['programCycleNumber'] !== null ? (string)(int)$c['programCycleNumber'] : '',
'programSessionNumber' => $c['programSessionNumber'] !== null ? (string)(int)$c['programSessionNumber'] : '',
'programSessionID' => $c['programSessionID'] ?? '',
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
'coach' => $c['coachUsername'] ?? $c['coachID'],
'supervisor' => $c['supervisorUsername'] ?? '',
'status' => $c['status'],
'sumPoints' => $c['sumPoints'],
];
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answerMap = [];
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
$answerMap[$a['questionID']] = $a;
}
foreach ($resultColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
}
$rows[] = $row;
}
return $rows;
}
function qdb_export_safe_basename(string $name): string {
$safe = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $name);
return $safe !== '' ? $safe : 'questionnaire';
}
/** @param array<string, true> $used */
function qdb_export_unique_zip_name(array &$used, string $base): string {
$name = $base;
$n = 2;
while (isset($used[$name])) {
$name = preg_replace('/(\.csv)$/i', "_{$n}$1", $base, 1, $count);
if (!$count) {
$name = $base . '_' . $n;
}
$n++;
}
$used[$name] = true;
return $name;
}
/**
* @param list<array<string, string>> $rows
* @param list<string> $fallbackHeader
*/
function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): string {
$fp = fopen('php://temp', 'r+');
if ($fp === false) {
return '';
}
fwrite($fp, "\xEF\xBB\xBF");
if (!empty($rows)) {
fputcsv($fp, array_keys($rows[0]), ',', '"', '\\');
foreach ($rows as $row) {
fputcsv($fp, array_values($row), ',', '"', '\\');
}
} elseif ($fallbackHeader !== []) {
fputcsv($fp, $fallbackHeader, ',', '"', '\\');
}
rewind($fp);
$csv = stream_get_contents($fp);
fclose($fp);
return $csv !== false ? $csv : '';
}
function qdb_export_current_csv_fallback_header(array $resultColumns): array {
$header = [
'clientCode', 'programCycleNumber', 'programSessionNumber', 'programSessionID',
'startedAt', 'completedAt', 'coach', 'supervisor', 'status', 'sumPoints',
];
foreach ($resultColumns as $col) {
$header[] = $col['header'];
}
return $header;
}
function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
$header = [
'clientCode', 'version', 'programCycleNumber', 'programSessionNumber', 'programSessionID',
'startedAt', 'completedAt', 'submittedAt',
'coach', 'supervisor', 'status', 'sumPoints',
'submissionID', 'structureRevision', 'submittedByRole', 'submittedBy',
];
foreach ($resultColumns as $col) {
$header[] = $col['header'];
}
return $header;
}
/**
* Build a ZIP of CSV exports for every questionnaire. Returns path to a temp file (caller must unlink).
*/
function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersions): string {
if (!class_exists('ZipArchive')) {
json_error('SERVER_ERROR', 'ZIP export is not available on this server', 500);
}
$qnRows = $pdo->query(
'SELECT questionnaireID, name, version FROM questionnaire ORDER BY orderIndex, name'
)->fetchAll(PDO::FETCH_ASSOC);
$tmpZip = tempnam(sys_get_temp_dir(), 'qdb_export_');
if ($tmpZip === false) {
json_error('SERVER_ERROR', 'Could not create export file', 500);
}
$zip = new ZipArchive();
if ($zip->open($tmpZip, ZipArchive::OVERWRITE) !== true) {
@unlink($tmpZip);
json_error('SERVER_ERROR', 'Could not create ZIP archive', 500);
}
$usedNames = [];
foreach ($qnRows as $qn) {
$qnID = $qn['questionnaireID'];
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
$questions = $ctx['questions'];
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
if ($allVersions) {
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
$fallback = qdb_export_all_versions_csv_fallback_header($resultColumns);
$base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv';
} else {
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
$fallback = qdb_export_current_csv_fallback_header($resultColumns);
$ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? ''));
$base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv';
}
$entryName = qdb_export_unique_zip_name($usedNames, $base);
$csv = qdb_export_rows_to_csv_string($rows, $fallback);
$zip->addFromString($entryName, $csv);
}
$readme = $allVersions
? "NAT-AS Ressourcenbarometer — all questionnaire response exports (every upload version).\nOne CSV per questionnaire.\n"
: "NAT-AS Ressourcenbarometer — all questionnaire response exports (current data).\nOne CSV per questionnaire.\n";
$zip->addFromString('README.txt', $readme);
$zip->close();
return $tmpZip;
}
/**
* Delete uploads, answers, and completions for client codes (does not delete client rows).
*
* @param list<string> $clientCodes
* @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int}
*/
function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
$clientCodes = array_values(array_unique(array_filter(
$clientCodes,
static fn($c) => is_string($c) && $c !== ''
)));
$deleted = [
'submissions' => 0,
'followup_notes' => 0,
'client_answers' => 0,
'completed_questionnaires' => 0,
];
if ($clientCodes === []) {
return $deleted;
}
foreach (array_chunk($clientCodes, 200) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '?'));
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['submissions'] += $stmt->rowCount();
}
if (qdb_table_exists($pdo, 'client_followup_note')) {
$stmt = $pdo->prepare("DELETE FROM client_followup_note WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['followup_notes'] += $stmt->rowCount();
}
$stmt = $pdo->prepare("DELETE FROM client_answer WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['client_answers'] += $stmt->rowCount();
$stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['completed_questionnaires'] += $stmt->rowCount();
}
return $deleted;
}
/**
* Remove submission rows assigned to coaches (FK before coach delete).
*
* @param list<string> $coachIDs
*/
function qdb_delete_submissions_for_coaches(PDO $pdo, array $coachIDs): int {
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
return 0;
}
$coachIDs = array_values(array_unique(array_filter(
$coachIDs,
static fn($id) => is_string($id) && $id !== ''
)));
if ($coachIDs === []) {
return 0;
}
$total = 0;
foreach (array_chunk($coachIDs, 200) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '?'));
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE assignedByCoach IN ($ph)");
$stmt->execute($chunk);
$total += $stmt->rowCount();
}
return $total;
}
/**
* @param array<string, array<string, mixed>> $answerMap questionID => answer row
* @param array<string, array<string, mixed>>|null $compareMap live answers to diff against
* @return list<array{label: string, value: string, changedFromLive: bool}>
*/
function qdb_answers_display_rows(
PDO $pdo,
array $resultColumns,
array $answerMap,
array $optionTextMap,
array &$stringLabelCache,
?array $compareMap = null
): array {
$rows = [];
foreach ($resultColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$value = qdb_display_column_cell_value($pdo, $col, $a, $optionTextMap, $stringLabelCache);
$changedFromLive = false;
if ($compareMap !== null) {
$liveValue = qdb_display_column_cell_value(
$pdo,
$col,
$compareMap[$qid] ?? null,
$optionTextMap,
$stringLabelCache
);
$changedFromLive = $value !== $liveValue;
}
$rows[] = [
'label' => $col['header'],
'value' => $value,
'changedFromLive' => $changedFromLive,
];
}
return $rows;
}
/**
* @param list<string> $questionIDs
* @return array<string, array<string, mixed>>
*/
function qdb_load_client_answer_map(
PDO $pdo,
string $clientCode,
array $questionIDs,
string $table = 'client_answer',
?string $submissionID = null
): array {
if ($questionIDs === []) {
return [];
}
$allowed = ['client_answer', 'client_answer_submission'];
if (!in_array($table, $allowed, true)) {
return [];
}
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
if ($table === 'client_answer_submission') {
if ($submissionID === null || $submissionID === '') {
return [];
}
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer_submission
WHERE submissionID = ? AND questionID IN ($ph)";
$params = array_merge([$submissionID], $questionIDs);
} else {
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer
WHERE clientCode = ? AND questionID IN ($ph)";
$params = array_merge([$clientCode], $questionIDs);
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$map = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$map[$row['questionID']] = $row;
}
return $map;
}
/**
* Full client response detail for the web clients list (RBAC-scoped).
*/
function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array {
$clientCode = trim($clientCode);
if ($clientCode === '') {
json_error('INVALID_FIELD', 'clientCode is required', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl', 'all');
$stmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
co.username AS coachUsername,
sv.username AS supervisorUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE cl.clientCode = :cc AND $rbacClause"
);
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
$client = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$client) {
json_error('NOT_FOUND', 'Client not found', 404);
}
$qnStmt = $pdo->prepare(
"SELECT q.questionnaireID, q.name, q.version AS questionnaireVersion, q.orderIndex
FROM questionnaire q
WHERE q.questionnaireID IN (
SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc
UNION
SELECT questionnaireID FROM questionnaire_submission WHERE clientCode = :cc2
)
ORDER BY q.orderIndex, q.name"
);
$qnStmt->execute([':cc' => $clientCode, ':cc2' => $clientCode]);
$qnRows = $qnStmt->fetchAll(PDO::FETCH_ASSOC);
$questionnaires = [];
foreach ($qnRows as $qn) {
$qnID = $qn['questionnaireID'];
$ctx = qdb_display_questionnaire_context($pdo, $qnID);
$questionIDs = array_column($ctx['questions'], 'questionID');
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
$stringLabelCache = $ctx['stringLabelCache'];
$cqStmt = $pdo->prepare(
'SELECT status, sumPoints, startedAt, completedAt
FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn'
);
$cqStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$cq = $cqStmt->fetch(PDO::FETCH_ASSOC) ?: [];
$liveMap = qdb_load_client_answer_map($pdo, $clientCode, $questionIDs);
$currentAnswers = qdb_answers_display_rows(
$pdo,
$resultColumns,
$liveMap,
$optionTextMap,
$stringLabelCache,
null
);
$subStmt = $pdo->prepare(
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt,
structureRevision, structureSnapshotJson
FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn
ORDER BY version DESC'
);
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$submissions = [];
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$versionColumns = $snapCtx['resultColumns'];
$versionOptionMap = $snapCtx['optionTextMap'];
$versionStringCache = $snapCtx['stringLabelCache'];
$versionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$versionColumns = $resultColumns;
$versionOptionMap = $optionTextMap;
$versionStringCache = $stringLabelCache;
$versionQuestionIDs = $questionIDs;
}
$subMap = qdb_load_client_answer_map(
$pdo,
$clientCode,
$versionQuestionIDs,
'client_answer_submission',
$s['submissionID']
);
$versionAnswers = qdb_answers_display_rows(
$pdo,
$versionColumns,
$subMap,
$versionOptionMap,
$versionStringCache,
$liveMap
);
$changedCount = count(array_filter(
$versionAnswers,
static fn($r) => !empty($r['changedFromLive'])
));
$submissions[] = [
'submissionID' => $s['submissionID'],
'version' => (int)$s['version'],
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'status' => $s['status'] ?? '',
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
'changedCount' => $changedCount,
'answers' => $versionAnswers,
];
}
$questionnaires[] = [
'questionnaireID' => $qnID,
'name' => $qn['name'],
'questionnaireVersion' => $qn['questionnaireVersion'] ?? '',
'status' => $cq['status'] ?? '',
'sumPoints' => isset($cq['sumPoints']) ? (int)$cq['sumPoints'] : null,
'startedAt' => !empty($cq['startedAt']) ? date('Y-m-d H:i', (int)$cq['startedAt']) : '',
'completedAt' => !empty($cq['completedAt']) ? date('Y-m-d H:i', (int)$cq['completedAt']) : '',
'submissionCount' => count($submissions),
'currentAnswers' => $currentAnswers,
'submissions' => $submissions,
];
}
return [
'client' => [
'clientCode' => $client['clientCode'],
'coachID' => $client['coachID'] ?? '',
'coachUsername' => $client['coachUsername'] ?? '',
'supervisorUsername' => $client['supervisorUsername'] ?? '',
],
'questionnaires' => $questionnaires,
'scoringProfiles' => qdb_client_scoring_profile_results($pdo, $clientCode),
];
}
function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array {
require_once __DIR__ . '/scoring.php';
$stmt = $pdo->prepare(
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc
ORDER BY sp.name'
);
$stmt->execute([':cc' => $clientCode]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bands = qdb_normalize_scoring_bands($row);
$coachBand = trim((string)($row['coachBand'] ?? ''));
$out[] = [
'profileID' => $row['profileID'],
'name' => $row['name'],
'weightedTotal' => (float)$row['weightedTotal'],
'band' => $row['band'],
'calculatedBand' => $row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'greenMin' => $bands['greenMin'],
'greenMax' => $bands['greenMax'],
'yellowMin' => $bands['yellowMin'],
'yellowMax' => $bands['yellowMax'],
'redMin' => $bands['redMin'],
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
'coachReviewedAt' => !empty($row['coachReviewedAt'])
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
];
}
return $out;
}
/**
* Active scoring profiles + per-client results for the clients list (RBAC-scoped).
*
* @return array{profiles: list<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
*/
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
if ($profiles === []) {
return ['profiles' => [], 'byClient' => []];
}
$stmt = $pdo->prepare(
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
FROM client_scoring_profile_result r
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
INNER JOIN client cl ON cl.clientCode = r.clientCode
WHERE ($rbacClause)"
);
$stmt->execute($rbacParams);
$byClient = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode'];
$coachBand = trim((string)($row['coachBand'] ?? ''));
$byClient[$cc][$row['profileID']] = [
'band' => (string)$row['band'],
'calculatedBand' => (string)$row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'weightedTotal' => (float)$row['weightedTotal'],
'name' => (string)$row['name'],
];
}
return [
'profiles' => array_map(static fn(array $p) => [
'profileID' => (string)$p['profileID'],
'name' => (string)$p['name'],
], $profiles),
'byClient' => $byClient,
];
}
/**
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
*/
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
$out = [];
foreach ($summary['profiles'] as $profile) {
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
$out[] = [
'profileID' => $profile['profileID'],
'name' => $profile['name'],
'band' => $result['calculatedBand'] ?? null,
'calculatedBand' => $result['calculatedBand'] ?? null,
'coachBand' => $result['coachBand'] ?? null,
'effectiveBand' => $result['effectiveBand'] ?? null,
'pendingReview' => $result['pendingReview'] ?? false,
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
];
}
return $out;
}