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> */ 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> */ 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 $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> $rows * @param list $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; }