added submission history, data insights, download all functionality

This commit is contained in:
2026-06-01 12:54:56 +02:00
parent 9a6fa22d84
commit bee7b74e53
19 changed files with 1863 additions and 110 deletions

304
lib/analytics.php Normal file
View File

@ -0,0 +1,304 @@
<?php
/**
* Analytics queries for Insights dashboard (RBAC-scoped).
*/
/**
* Daily upload counts for the last N calendar days (zeros for quiet days).
*
* @return list<array{date: string, count: int}>
*/
function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days = 14): array {
$days = max(1, min(90, $days));
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$today = strtotime('today');
$startTs = $today - ($days - 1) * 86400;
$stmt = $pdo->prepare(
"SELECT strftime('%Y-%m-%d', qs.submittedAt, 'unixepoch') AS d, COUNT(*) AS cnt
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE $rbacClause AND qs.submittedAt >= :start
GROUP BY d
ORDER BY d"
);
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
$byDate = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$byDate[$row['d']] = (int)$row['cnt'];
}
$out = [];
for ($i = 0; $i < $days; $i++) {
$ts = $startTs + $i * 86400;
$key = date('Y-m-d', $ts);
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
}
return $out;
}
function qdb_analytics_overview(PDO $pdo, array $tokenRec): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$stmt = $pdo->prepare("SELECT COUNT(*) FROM client cl WHERE $rbacClause");
$stmt->execute($rbacParams);
$clientCount = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE $rbacClause"
);
$stmt->execute($rbacParams);
$clientsWithCompletions = (int)$stmt->fetchColumn();
$now = time();
$d7 = $now - 7 * 86400;
$d30 = $now - 30 * 86400;
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE $rbacClause AND qs.submittedAt >= :t"
);
$stmt->execute(array_merge($rbacParams, [':t' => $d7]));
$submissions7d = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE $rbacClause AND qs.submittedAt >= :t"
);
$stmt->execute(array_merge($rbacParams, [':t' => $d30]));
$submissions30d = (int)$stmt->fetchColumn();
$qnRows = $pdo->query(
"SELECT questionnaireID, name, orderIndex FROM questionnaire WHERE state = 'active' ORDER BY orderIndex, name"
)->fetchAll(PDO::FETCH_ASSOC);
$perQuestionnaire = [];
foreach ($qnRows as $qn) {
$qnID = $qn['questionnaireID'];
$stmt = $pdo->prepare(
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE cq.questionnaireID = :qn AND $rbacClause"
);
$stmt->execute(array_merge([':qn' => $qnID], $rbacParams));
$completed = (int)$stmt->fetchColumn();
$ratio = $clientCount > 0 ? round(100 * $completed / $clientCount, 1) : 0;
$perQuestionnaire[] = [
'questionnaireID' => $qnID,
'name' => $qn['name'],
'completedCount' => $completed,
'eligibleCount' => $clientCount,
'completionRatio' => $ratio,
];
}
return [
'clientCount' => $clientCount,
'clientsWithCompletions' => $clientsWithCompletions,
'submissionsLast7d' => $submissions7d,
'submissionsLast30d' => $submissions30d,
'questionnaires' => $perQuestionnaire,
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
];
}
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$now = time();
$sql = "
SELECT cl.clientCode,
co.username AS coachUsername,
co.coachID,
fc.firstCompletedAt,
fc.firstQuestionnaireID,
qn.name AS firstQuestionnaireName,
la.lastActivityAt,
n.note AS followupNote
FROM client cl
INNER JOIN (
SELECT cq.clientCode, cq.questionnaireID AS firstQuestionnaireID, cq.completedAt AS firstCompletedAt
FROM completed_questionnaire cq
WHERE cq.completedAt IS NOT NULL
AND cq.rowid = (
SELECT c2.rowid FROM completed_questionnaire c2
WHERE c2.clientCode = cq.clientCode AND c2.completedAt IS NOT NULL
ORDER BY c2.completedAt ASC, c2.questionnaireID ASC
LIMIT 1
)
) fc ON fc.clientCode = cl.clientCode
LEFT JOIN questionnaire qn ON qn.questionnaireID = fc.firstQuestionnaireID
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN (
SELECT qs.clientCode, MAX(qs.submittedAt) AS lastActivityAt
FROM questionnaire_submission qs
GROUP BY qs.clientCode
) la ON la.clientCode = cl.clientCode
LEFT JOIN client_followup_note n ON n.clientCode = cl.clientCode
WHERE $rbacClause
ORDER BY COALESCE(la.lastActivityAt, 0) ASC, cl.clientCode ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($rbacParams);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $r) {
$lastAt = $r['lastActivityAt'] !== null ? (int)$r['lastActivityAt'] : null;
$daysSince = $lastAt !== null
? (int)floor(($now - $lastAt) / 86400)
: null;
$out[] = [
'clientCode' => $r['clientCode'],
'coachUsername' => $r['coachUsername'] ?? $r['coachID'],
'firstQuestionnaireID' => $r['firstQuestionnaireID'],
'firstQuestionnaireName'=> $r['firstQuestionnaireName'] ?? $r['firstQuestionnaireID'],
'firstCompletedAt' => $r['firstCompletedAt']
? date('Y-m-d H:i', (int)$r['firstCompletedAt']) : '',
'lastActivityAt' => $lastAt ? date('Y-m-d H:i', $lastAt) : '',
'daysSinceLastActivity' => $daysSince,
'note' => $r['followupNote'] ?? '',
];
}
return $out;
}
function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void {
$clientCode = trim($clientCode);
if ($clientCode === '') {
json_error('INVALID_FIELD', 'clientCode is required', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$stmt = $pdo->prepare("SELECT 1 FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)");
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$stmt->fetchColumn()) {
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$pdo->prepare(
'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt)
VALUES (:cc, :n, :uid, :ts)
ON CONFLICT(clientCode) DO UPDATE SET
note = excluded.note,
updatedByUserID = excluded.updatedByUserID,
updatedAt = excluded.updatedAt'
)->execute([
':cc' => $clientCode,
':n' => $note,
':uid' => $tokenRec['userID'] ?? '',
':ts' => time(),
]);
}
function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
if ($role === 'supervisor') {
$coachWhere = 'co.supervisorID = :eid';
$coachParams = [':eid' => $entityID];
} else {
$coachWhere = '1=1';
$coachParams = [];
}
$now = time();
$d7 = $now - 7 * 86400;
$d30 = $now - 30 * 86400;
$sql = "
SELECT co.coachID, co.username,
sv.username AS supervisorUsername,
(SELECT COUNT(*) FROM client c WHERE c.coachID = co.coachID) AS clientCount,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS totalSubmissions,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d7) AS submissions7d,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d30) AS submissions30d,
(SELECT MAX(qs.submittedAt) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS lastSubmissionAt
FROM coach co
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $coachWhere
ORDER BY co.username
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':d7' => $d7, ':d30' => $d30], $coachParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $r) {
$last = $r['lastSubmissionAt'] !== null ? (int)$r['lastSubmissionAt'] : null;
$out[] = [
'coachID' => $r['coachID'],
'username' => $r['username'],
'supervisorUsername'=> $r['supervisorUsername'] ?? '',
'clientCount' => (int)$r['clientCount'],
'totalSubmissions' => (int)$r['totalSubmissions'],
'submissionsLast7d' => (int)$r['submissions7d'],
'submissionsLast30d'=> (int)$r['submissions30d'],
'lastSubmissionAt' => $last ? date('Y-m-d H:i', $last) : '',
];
}
return $out;
}
function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID, int $limit = 25): array {
$coachID = trim($coachID);
if ($coachID === '') {
json_error('INVALID_FIELD', 'coachID is required', 400);
}
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
$chk = $pdo->prepare('SELECT coachID, supervisorID FROM coach WHERE coachID = :id');
$chk->execute([':id' => $coachID]);
$coach = $chk->fetch(PDO::FETCH_ASSOC);
if (!$coach) {
json_error('NOT_FOUND', 'Coach not found', 404);
}
if ($role === 'supervisor' && ($coach['supervisorID'] ?? '') !== $entityID) {
json_error('FORBIDDEN', 'Not authorized for this coach', 403);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$limit = max(1, min($limit, 100));
$sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole,
qs.clientCode, qs.questionnaireID, qn.name AS questionnaireName
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID
WHERE cl.coachID = :cid AND ($rbacClause)
ORDER BY qs.submittedAt DESC
LIMIT $limit
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':cid' => $coachID], $rbacParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(static function ($r) {
return [
'submissionID' => $r['submissionID'],
'version' => (int)$r['version'],
'submittedAt' => $r['submittedAt'] ? date('Y-m-d H:i', (int)$r['submittedAt']) : '',
'submittedByRole' => $r['submittedByRole'] ?? '',
'clientCode' => $r['clientCode'],
'questionnaireID' => $r['questionnaireID'],
'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'],
];
}, $rows);
}

View File

@ -768,6 +768,15 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array
$pdo->exec('PRAGMA foreign_keys = OFF');
$deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
if (qdb_table_exists($pdo, 'client_answer_submission')) {
$pdo->exec('DELETE FROM client_answer_submission');
}
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->exec('DELETE FROM questionnaire_submission');
}
if (qdb_table_exists($pdo, 'client_followup_note')) {
$pdo->exec('DELETE FROM client_followup_note');
}
$deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire');
$deleted['clients'] = (int)$pdo->exec('DELETE FROM client');

453
lib/submissions.php Normal file
View File

@ -0,0 +1,453 @@
<?php
/**
* Questionnaire submission versioning (archive each coach upload).
*/
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
): string {
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
$submissionID = bin2hex(random_bytes(16));
$now = time();
$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
) VALUES (
:sid, :cc, :qn, :ver, :sat,
:uid, :role, :abc,
:st, :sa, :ca, :sp
)'
)->execute([
':sid' => $submissionID,
':cc' => $clientCode,
':qn' => $questionnaireID,
':ver' => $version,
':sat' => $now,
':uid' => $tokenRec['userID'] ?? '',
':role' => $tokenRec['role'] ?? '',
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
':st' => $status,
':sa' => $startedAt,
':ca' => $completedAt,
':sp' => $sumPoints,
]);
$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;
}
}
/**
* 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 {
[$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,
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);
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
$qPlaceholders = !empty($questionIDs)
? implode(',', array_fill(0, count($questionIDs), '?'))
: "'__none__'";
$answerStmt = $pdo->prepare("
SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer_submission
WHERE submissionID = ? AND questionID IN ($qPlaceholders)
");
$userMap = [];
$userStmt = $pdo->query('SELECT userID, username FROM users');
foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) {
$userMap[$u['userID']] = $u['username'];
}
$rows = [];
foreach ($submissions as $s) {
$row = [
'submissionID' => $s['submissionID'],
'version' => (string)(int)$s['version'],
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'submittedByRole'=> $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
'clientCode' => $s['clientCode'],
'coach' => $s['coachUsername'] ?? $s['coachID'],
'supervisor' => $s['supervisorUsername'] ?? '',
'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] ?? '',
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
];
$bindParams = array_merge([$s['submissionID']], $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;
}
/**
* @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
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'],
'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);
$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', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($resultColumns as $col) {
$header[] = $col['header'];
}
return $header;
}
function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
$header = [
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy',
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt',
];
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
? "BW Schützt — all questionnaire response exports (every upload version).\nOne CSV per questionnaire.\n"
: "BW Schützt — all questionnaire response exports (current data).\nOne CSV per questionnaire.\n";
$zip->addFromString('README.txt', $readme);
$zip->close();
return $tmpZip;
}