made client data browsable with dropdown and version diffs

This commit is contained in:
2026-06-01 13:29:10 +02:00
parent bee7b74e53
commit 3d56abd124
10 changed files with 971 additions and 83 deletions

View File

@ -12,27 +12,24 @@ function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days =
$days = max(1, min(90, $days));
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$today = strtotime('today');
$startTs = $today - ($days - 1) * 86400;
$startTs = strtotime('today', time()) - ($days - 1) * 86400;
$stmt = $pdo->prepare(
"SELECT strftime('%Y-%m-%d', qs.submittedAt, 'unixepoch') AS d, COUNT(*) AS cnt
"SELECT qs.submittedAt
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"
WHERE $rbacClause AND qs.submittedAt >= :start"
);
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
$byDate = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$byDate[$row['d']] = (int)$row['cnt'];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $submittedAt) {
$key = date('Y-m-d', (int)$submittedAt);
$byDate[$key] = ($byDate[$key] ?? 0) + 1;
}
$out = [];
for ($i = 0; $i < $days; $i++) {
$ts = $startTs + $i * 86400;
$key = date('Y-m-d', $ts);
$key = date('Y-m-d', $startTs + $i * 86400);
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
}
return $out;

View File

@ -15,6 +15,7 @@ const QDB_DEV_GLASS_POINTS = [
];
require_once __DIR__ . '/app_answers.php';
require_once __DIR__ . '/submissions.php';
/**
* @return array{imported: array, skipped: array, errors: string[]}
@ -33,6 +34,7 @@ function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
'coaches' => 0,
'clients' => 0,
'completions' => 0,
'submissions' => 0,
'answers' => 0,
];
$skipped = [
@ -165,21 +167,57 @@ function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
continue;
}
$completedAt = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($variant % 90) * 86400);
$startedAt = isset($row['startedAt']) ? (int)$row['startedAt'] : ($completedAt - 600);
$answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variant);
$answerCount = qdb_dev_persist_submission(
$pdo,
$qnID,
$clientCode,
(string)$coachID,
$answers,
$startedAt,
$completedAt
$coachUserStmt = $pdo->prepare(
"SELECT u.userID FROM users u
WHERE u.role = 'coach' AND u.entityID = :cid LIMIT 1"
);
$coachUserStmt->execute([':cid' => $coachID]);
$coachUserID = (string)($coachUserStmt->fetchColumn() ?: '');
$tokenRec = ['userID' => $coachUserID, 'role' => 'coach'];
$uploads = qdb_dev_normalize_uploads($row, $variant, $now);
$archiveSubmissions = qdb_table_exists($pdo, 'questionnaire_submission');
foreach ($uploads as $uploadIndex => $upload) {
$variantSeed = (int)($upload['variant'] ?? ($variant + $uploadIndex));
$completedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
$startedAt = (int)($upload['startedAt'] ?? ($completedAt - 600));
$answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variantSeed);
$answerCount = qdb_dev_persist_submission(
$pdo,
$qnID,
$clientCode,
(string)$coachID,
$answers,
$startedAt,
$completedAt
);
$imported['answers'] += $answerCount;
if ($archiveSubmissions) {
$spStmt = $pdo->prepare(
'SELECT sumPoints FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn'
);
$spStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$sumPoints = (int)($spStmt->fetchColumn() ?: 0);
qdb_record_submission_after_submit(
$pdo,
$clientCode,
$qnID,
$tokenRec,
$startedAt,
$completedAt,
$sumPoints,
(string)$coachID,
$completedAt
);
$imported['submissions']++;
}
}
$imported['completions']++;
$imported['answers'] += $answerCount;
$variant++;
}
@ -200,12 +238,56 @@ function qdb_dev_validate_fixture(array $fixture): string
if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) {
json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400);
}
if ((int)($fixture['fixtureVersion'] ?? 0) !== 1) {
json_error('INVALID_FIELD', 'fixtureVersion must be 1', 400);
$fixtureVersion = (int)($fixture['fixtureVersion'] ?? 0);
if ($fixtureVersion !== 1 && $fixtureVersion !== 2) {
json_error('INVALID_FIELD', 'fixtureVersion must be 1 or 2', 400);
}
return $prefix;
}
/**
* @return list<array{submittedAt: int, startedAt: int, variant: int}>
*/
function qdb_dev_normalize_uploads(array $row, int $fallbackVariant, int $now): array
{
if (!empty($row['uploads']) && is_array($row['uploads'])) {
$out = [];
foreach ($row['uploads'] as $i => $upload) {
if (!is_array($upload)) {
continue;
}
$submittedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
$startedAt = (int)($upload['startedAt'] ?? ($submittedAt - 600));
$out[] = [
'submittedAt' => $submittedAt,
'startedAt' => $startedAt,
'variant' => (int)($upload['variant'] ?? ($fallbackVariant + $i)),
];
}
if ($out !== []) {
usort($out, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
return $out;
}
}
$versionCount = max(1, (int)($row['versions'] ?? $row['uploadCount'] ?? 1));
$finalCompleted = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($fallbackVariant % 90) * 86400);
$uploads = [];
for ($v = 0; $v < $versionCount; $v++) {
$gapDays = ($versionCount - 1 - $v) * 14 + ($fallbackVariant % 5);
$completedAt = $finalCompleted - ($gapDays * 86400);
$uploads[] = [
'submittedAt' => $completedAt,
'startedAt' => isset($row['startedAt']) && $v === $versionCount - 1
? (int)$row['startedAt']
: ($completedAt - 600 - ($v * 120)),
'variant' => $fallbackVariant + $v,
];
}
usort($uploads, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
return $uploads;
}
function qdb_dev_has_prefix(string $value, string $prefix): bool
{
return str_starts_with(strtolower($value), strtolower($prefix))
@ -603,6 +685,8 @@ function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
$clientLike = $clientPrefix . '%';
$deleted = [
'submissions' => 0,
'followup_notes' => 0,
'client_answers' => 0,
'completed_questionnaires' => 0,
'clients' => 0,
@ -673,17 +757,15 @@ function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
$pdo->beginTransaction();
try {
$deleted['client_answers'] = qdb_dev_delete_by_ids($pdo, 'client_answer', 'clientCode', $clientCodeList);
if ($clientCodeList !== []) {
foreach (array_chunk($clientCodeList, 200) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '?'));
$delCq = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
$delCq->execute($chunk);
$deleted['completed_questionnaires'] += $delCq->rowCount();
}
$responseDeleted = qdb_delete_client_response_data($pdo, $clientCodeList);
$deleted['submissions'] += $responseDeleted['submissions'];
$deleted['followup_notes'] += $responseDeleted['followup_notes'];
$deleted['client_answers'] += $responseDeleted['client_answers'];
$deleted['completed_questionnaires'] += $responseDeleted['completed_questionnaires'];
}
if ($coachIdList !== []) {
$deleted['submissions'] += qdb_delete_submissions_for_coaches($pdo, $coachIdList);
$deleted['completed_questionnaires'] += qdb_dev_delete_by_ids(
$pdo,
'completed_questionnaire',

View File

@ -23,11 +23,12 @@ function qdb_record_submission_after_submit(
?int $startedAt,
?int $completedAt,
int $sumPoints,
string $assignedByCoach
string $assignedByCoach,
?int $submittedAt = null
): string {
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
$submissionID = bin2hex(random_bytes(16));
$now = time();
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
$cq = $pdo->prepare(
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
@ -50,7 +51,7 @@ function qdb_record_submission_after_submit(
':cc' => $clientCode,
':qn' => $questionnaireID,
':ver' => $version,
':sat' => $now,
':sat' => $submittedAtValue,
':uid' => $tokenRec['userID'] ?? '',
':role' => $tokenRec['role'] ?? '',
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
@ -451,3 +452,285 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion
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');
$stmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID,
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
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) {
$subMap = qdb_load_client_answer_map(
$pdo,
$clientCode,
$questionIDs,
'client_answer_submission',
$s['submissionID']
);
$versionAnswers = qdb_answers_display_rows(
$pdo,
$resultColumns,
$subMap,
$optionTextMap,
$stringLabelCache,
$liveMap
);
$changedCount = count(array_filter(
$versionAnswers,
static fn($r) => !empty($r['changedFromLive'])
));
$submissions[] = [
'submissionID' => $s['submissionID'],
'version' => (int)$s['version'],
'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,
];
}