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 (qdb_is_non_stored_answer_question_type($type)) { continue; } 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> */ 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 */ 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} */ 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> */ 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> */ 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> */ 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 $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', '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 $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 $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> $answerMap questionID => answer row * @param array>|null $compareMap live answers to diff against * @return list */ 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 $questionIDs * @return array> */ 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, byClient: array>} */ 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 */ 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; }