added submission history, data insights, download all functionality
This commit is contained in:
453
lib/submissions.php
Normal file
453
lib/submissions.php
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user