From 52d4ef42dcc3e80d14d7dbaf21100d1367743093 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 22 May 2026 13:26:29 +0200 Subject: [PATCH] updated api --- api/index.php | 3 + db_init.php | 6 +- docs/android-questionnaire-api.md | 24 +++- handlers/activity.php | 91 +++++++++++++ handlers/app_questionnaires.php | 218 +++++++++++++++++++++++++----- handlers/assignments.php | 13 ++ handlers/auth.php | 26 +++- handlers/client_export.php | 178 ++++++++++++++++++++++++ handlers/clients.php | 13 ++ handlers/users.php | 8 ++ lib/activity.php | 56 ++++++++ lib/migrate_v4.php | 81 +++++++++++ schema.sql | 46 +++++++ website/index.html | 6 + website/js/app.js | 2 + website/js/pages/activity.js | 104 ++++++++++++++ website/js/pages/clients.js | 33 +++++ 17 files changed, 867 insertions(+), 41 deletions(-) create mode 100644 handlers/activity.php create mode 100644 handlers/client_export.php create mode 100644 lib/activity.php create mode 100644 lib/migrate_v4.php create mode 100644 website/js/pages/activity.js diff --git a/api/index.php b/api/index.php index 37852ee..995eced 100644 --- a/api/index.php +++ b/api/index.php @@ -39,6 +39,9 @@ $routes = [ 'users' => __DIR__ . '/../handlers/users.php', 'assignments' => __DIR__ . '/../handlers/assignments.php', 'clients' => __DIR__ . '/../handlers/clients.php', + 'clients/export' => __DIR__ . '/../handlers/client_export.php', + 'clients/export_all' => __DIR__ . '/../handlers/client_export.php', + 'activity' => __DIR__ . '/../handlers/activity.php', 'results' => __DIR__ . '/../handlers/results.php', 'export' => __DIR__ . '/../handlers/export.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', diff --git a/db_init.php b/db_init.php index 54a4266..ef81129 100644 --- a/db_init.php +++ b/db_init.php @@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php'; define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 3); +define('QDB_VERSION', 4); /** * Decrypt the master DB file into a temp SQLite file, or create a fresh one @@ -72,6 +72,10 @@ function qdb_open(bool $writable = false): array { if ($currentVersion < QDB_VERSION) { $pdo->exec("PRAGMA foreign_keys = OFF;"); $pdo->exec($sql); + require_once __DIR__ . '/lib/migrate_v4.php'; + if ($currentVersion < 4) { + qdb_migrate_v4($pdo); + } $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); $pdo->exec("PRAGMA foreign_keys = ON;"); } diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 67f3200..4d3ca63 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -254,7 +254,8 @@ Fields: - `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none. - `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint. - `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire. -- `clients[].completedQuestionnaires[].completedAt`: Unix timestamp (seconds) of when the questionnaire was completed, or `null` if not recorded. +- `clients[].completedQuestionnaires[].completedAt`: when the questionnaire was completed on the device (milliseconds), or `null`. +- `clients[].completedQuestionnaires[].uploadedAt`: when the latest version was received on the server (milliseconds), or `null`. Common failures: @@ -368,14 +369,16 @@ Answer fields: - `numericValue`: for numeric/range answers. - `answeredAt`: timestamp for this answer. -Current upload behavior: +Upload behavior: - Unknown or empty `questionID` values are skipped. - Unknown `answerOptionKey` values are stored as no selected option for that question. -- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer. -- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum. +- Each successful POST **appends** an immutable `questionnaire_submission` record (version increments per client + questionnaire). +- The latest answers are also written to the `client_answer` cache used by web results/CSV (always shows newest only). +- `uploadedAt` is set by the server at POST time (milliseconds); do not send it from the client. +- `completedAt` in the request is the device completion time; it is stored separately from `uploadedAt`. - `sumPoints` is calculated from recognized `answerOptionKey` values. -- The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question. +- Coaches may re-open, edit, and POST again; each upload creates a new submission version. Success: @@ -384,11 +387,20 @@ Success: "ok": true, "data": { "submitted": true, - "sumPoints": 0 + "sumPoints": 0, + "submissionID": "sub_…", + "uploadedAt": 1714471800123, + "submissionVersion": 2 } } ``` +## Fetch Latest Answers (re-edit) + +`GET /api/app_questionnaires?clientCode={code}&questionnaireID={id}&answers=1` + +Returns the **latest** cached answers for a client/questionnaire in the same shape as POST `answers[]`. Use before reopening a completed questionnaire on the tablet. + Common failures: - `400 INVALID_BODY`: request body is not valid JSON. diff --git a/handlers/activity.php b/handlers/activity.php new file mode 100644 index 0000000..367d50e --- /dev/null +++ b/handlers/activity.php @@ -0,0 +1,91 @@ += :since'; + $params[':since'] = $since; +} +if ($until !== null) { + $where[] = 'ae.createdAt <= :until'; + $params[':until'] = $until; +} +if ($action !== '') { + $where[] = 'ae.action = :action'; + $params[':action'] = $action; +} + +$whereSql = implode(' AND ', $where); + +$countStmt = $pdo->prepare("SELECT COUNT(*) FROM activity_event ae WHERE $whereSql"); +$countStmt->execute($params); +$total = (int)$countStmt->fetchColumn(); + +$sql = " + SELECT ae.eventID, ae.createdAt, ae.actorUserID, ae.actorUsername, ae.actorRole, + ae.actorEntityID, ae.action, ae.targetType, ae.targetID, ae.summary, ae.detailsJson + FROM activity_event ae + WHERE $whereSql + ORDER BY ae.createdAt DESC + LIMIT :lim OFFSET :off +"; +$stmt = $pdo->prepare($sql); +foreach ($params as $k => $v) { + $stmt->bindValue($k, $v); +} +$stmt->bindValue(':lim', $limit, PDO::PARAM_INT); +$stmt->bindValue(':off', $offset, PDO::PARAM_INT); +$stmt->execute(); +$events = $stmt->fetchAll(PDO::FETCH_ASSOC); + +foreach ($events as &$e) { + $e['createdAt'] = (int)$e['createdAt']; + $e['details'] = json_decode($e['detailsJson'] ?? '{}', true) ?: []; + unset($e['detailsJson']); +} +unset($e); + +qdb_discard($tmpDb, $lockFp); + +json_success([ + 'events' => $events, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, +]); diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 9dbc26b..0f70697 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -5,9 +5,12 @@ * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> all translations merged across all tables * GET ?clients=1 -> list of clients assigned to the authenticated coach + * GET ?clientCode=&questionnaireID=&answers=1 -> latest answers for re-edit * POST -> submit interview answers for a client (coach / supervisor / admin) */ +require_once __DIR__ . '/../lib/activity.php'; + $tokenRec = require_valid_token(); if ($method === 'POST') { @@ -78,9 +81,74 @@ if ($method === 'POST') { ]; } + $uploadedAt = (int)(microtime(true) * 1000); + $submissionID = 'sub_' . bin2hex(random_bytes(12)); + $submissionVersion = next_submission_version($pdo, $clientCode, $qnID); + $submittedByUserID = $tokenRec['userID'] ?? null; + $pdo->beginTransaction(); $sumPoints = 0; + $parsedAnswers = []; + + foreach ($answers as $a) { + $shortId = trim($a['questionID'] ?? ''); + if ($shortId === '') continue; + + $fullQID = $shortIdMap[$shortId] ?? null; + if ($fullQID === null) continue; + + $answerOptionID = null; + $answerOptionKey = null; + $freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null; + $numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null; + $answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null; + + $optionKey = $a['answerOptionKey'] ?? null; + if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { + $opt = $optionMap[$fullQID][(string)$optionKey]; + $answerOptionID = $opt['answerOptionID']; + $answerOptionKey = (string)$optionKey; + $sumPoints += $opt['points']; + } + + $parsedAnswers[] = [ + 'shortId' => $shortId, + 'fullQID' => $fullQID, + 'answerOptionID' => $answerOptionID, + 'answerOptionKey' => $answerOptionKey, + 'freeTextValue' => $freeTextValue, + 'numericValue' => $numericValue, + 'answeredAt' => $answeredAt, + ]; + } + + $pdo->prepare(" + INSERT INTO questionnaire_submission ( + submissionID, clientCode, questionnaireID, assignedByCoach, status, + startedAt, completedAt, uploadedAt, sumPoints, submissionVersion, + submittedByUserID, submittedByRole + ) VALUES ( + :sid, :cc, :qn, :abc, 'completed', :sa, :ca, :ua, :sp, :sv, :uid, :role + ) + ")->execute([ + ':sid' => $submissionID, + ':cc' => $clientCode, + ':qn' => $qnID, + ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null, + ':sa' => $startedAt, + ':ca' => $completedAt, + ':ua' => $uploadedAt, + ':sp' => $sumPoints, + ':sv' => $submissionVersion, + ':uid' => $submittedByUserID, + ':role' => $tokenRec['role'] ?? '', + ]); + + $subAnsInsert = $pdo->prepare(" + INSERT INTO submission_answer (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) + VALUES (:sid, :qid, :aoid, :ftv, :nv, :at) + "); $answerInsert = $pdo->prepare(" INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) @@ -91,46 +159,34 @@ if ($method === 'POST') { answeredAt = excluded.answeredAt "); - foreach ($answers as $a) { - $shortId = trim($a['questionID'] ?? ''); - if ($shortId === '') continue; - - // Resolve short ID to full questionID - $fullQID = $shortIdMap[$shortId] ?? null; - if ($fullQID === null) continue; // skip unknown questions - - $answerOptionID = null; - $freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null; - $numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null; - $answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null; - - // Resolve option key to answerOptionID and accumulate points - $optionKey = $a['answerOptionKey'] ?? null; - if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { - $opt = $optionMap[$fullQID][(string)$optionKey]; - $answerOptionID = $opt['answerOptionID']; - $sumPoints += $opt['points']; - } - + foreach ($parsedAnswers as $pa) { + $subAnsInsert->execute([ + ':sid' => $submissionID, + ':qid' => $pa['fullQID'], + ':aoid' => $pa['answerOptionID'], + ':ftv' => $pa['freeTextValue'], + ':nv' => $pa['numericValue'], + ':at' => $pa['answeredAt'], + ]); $answerInsert->execute([ ':cc' => $clientCode, - ':qid' => $fullQID, - ':aoid' => $answerOptionID, - ':ftv' => $freeTextValue, - ':nv' => $numericValue, - ':at' => $answeredAt, + ':qid' => $pa['fullQID'], + ':aoid' => $pa['answerOptionID'], + ':ftv' => $pa['freeTextValue'], + ':nv' => $pa['numericValue'], + ':at' => $pa['answeredAt'], ]); } - // Upsert completed_questionnaire record $pdo->prepare(" - INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) - VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp) + INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, uploadedAt, sumPoints) + VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :ua, :sp) ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET assignedByCoach = excluded.assignedByCoach, status = 'completed', startedAt = COALESCE(excluded.startedAt, startedAt), completedAt = excluded.completedAt, + uploadedAt = excluded.uploadedAt, sumPoints = excluded.sumPoints ")->execute([ ':cc' => $clientCode, @@ -138,13 +194,39 @@ if ($method === 'POST') { ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null, ':sa' => $startedAt, ':ca' => $completedAt, + ':ua' => $uploadedAt, ':sp' => $sumPoints, ]); + log_activity( + $pdo, + $tokenRec, + 'questionnaire_submitted', + 'client', + $clientCode, + "Uploaded $qnID for $clientCode (v$submissionVersion)", + [ + 'submissionID' => $submissionID, + 'clientCode' => $clientCode, + 'questionnaireID' => $qnID, + 'uploadedAt' => $uploadedAt, + 'completedAt' => $completedAt, + 'submissionVersion' => $submissionVersion, + 'sumPoints' => $sumPoints, + ], + $uploadedAt + ); + $pdo->commit(); qdb_save($tmpDb, $lockFp); - json_success(['submitted' => true, 'sumPoints' => $sumPoints]); + json_success([ + 'submitted' => true, + 'sumPoints' => $sumPoints, + 'submissionID' => $submissionID, + 'uploadedAt' => $uploadedAt, + 'submissionVersion' => $submissionVersion, + ]); } catch (Throwable $e) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); @@ -159,9 +241,80 @@ if ($method !== 'GET') { [$pdo, $tmpDb, $lockFp] = qdb_open(false); -$qnID = $_GET['id'] ?? ''; +$qnID = trim($_GET['id'] ?? $_GET['questionnaireID'] ?? ''); $translations = $_GET['translations'] ?? ''; $fetchClients = $_GET['clients'] ?? ''; +$clientCode = trim($_GET['clientCode'] ?? ''); +$fetchAnswers = ($_GET['answers'] ?? '') === '1' || ($_GET['answers'] ?? '') === 'true'; + +if ($fetchAnswers && $clientCode !== '' && $qnID !== '') { + require_role(['admin', 'supervisor', 'coach'], $tokenRec); + + [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + $clStmt = $pdo->prepare( + "SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" + ); + $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); + if (!$clStmt->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Client not found or not authorized', 404); + } + + $qRows = $pdo->prepare("SELECT questionID FROM question WHERE questionnaireID = :qn"); + $qRows->execute([':qn' => $qnID]); + $fullToShort = []; + foreach ($qRows->fetchAll(PDO::FETCH_COLUMN) as $fullId) { + $parts = explode('__', $fullId); + $fullToShort[$fullId] = end($parts); + } + + $aoRows = $pdo->prepare(" + SELECT ao.answerOptionID, ao.questionID, ao.defaultText + FROM answer_option ao + JOIN question q ON q.questionID = ao.questionID + WHERE q.questionnaireID = :qn + "); + $aoRows->execute([':qn' => $qnID]); + $optionKeyById = []; + foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionKeyById[$ao['answerOptionID']] = $ao['defaultText']; + } + + $ansStmt = $pdo->prepare(" + SELECT questionID, answerOptionID, freeTextValue, numericValue, answeredAt + FROM client_answer + WHERE clientCode = :cc AND questionID IN ( + SELECT questionID FROM question WHERE questionnaireID = :qn + ) + "); + $ansStmt->execute([':cc' => $clientCode, ':qn' => $qnID]); + $outAnswers = []; + foreach ($ansStmt->fetchAll(PDO::FETCH_ASSOC) as $a) { + $shortId = $fullToShort[$a['questionID']] ?? null; + if ($shortId === null) continue; + $row = ['questionID' => $shortId]; + if ($a['answerOptionID'] && isset($optionKeyById[$a['answerOptionID']])) { + $row['answerOptionKey'] = $optionKeyById[$a['answerOptionID']]; + } + if ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') { + $row['freeTextValue'] = $a['freeTextValue']; + } + if ($a['numericValue'] !== null) { + $row['numericValue'] = (float)$a['numericValue']; + } + if ($a['answeredAt'] !== null) { + $row['answeredAt'] = (int)$a['answeredAt']; + } + $outAnswers[] = $row; + } + + qdb_discard($tmpDb, $lockFp); + json_success([ + 'clientCode' => $clientCode, + 'questionnaireID' => $qnID, + 'answers' => $outAnswers, + ]); +} if ($fetchClients) { require_role(['coach'], $tokenRec); @@ -181,7 +334,7 @@ if ($fetchClients) { if (!empty($clientCodes)) { $placeholders = implode(',', array_fill(0, count($clientCodes), '?')); $stmt2 = $pdo->prepare(" - SELECT clientCode, questionnaireID, sumPoints, completedAt + SELECT clientCode, questionnaireID, sumPoints, completedAt, uploadedAt FROM completed_questionnaire WHERE clientCode IN ($placeholders) "); @@ -191,6 +344,7 @@ if ($fetchClients) { 'questionnaireID' => $row['questionnaireID'], 'sumPoints' => (int) $row['sumPoints'], 'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null, + 'uploadedAt' => $row['uploadedAt'] !== null ? (int) $row['uploadedAt'] : null, ]; } } diff --git a/handlers/assignments.php b/handlers/assignments.php index 40149fa..6b63f30 100644 --- a/handlers/assignments.php +++ b/handlers/assignments.php @@ -1,5 +1,7 @@ rowCount(); } $pdo->commit(); + + log_activity( + $pdo, + $tokenRec, + 'clients_assigned', + 'coach', + $coachID, + "Assigned $count client(s) to coach $coachID", + ['clientCodes' => $clientCodes, 'count' => $count] + ); + qdb_save($tmpDb, $lockFp); json_success(['assigned' => $count]); diff --git a/handlers/auth.php b/handlers/auth.php index cff7db5..68dfa16 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -1,5 +1,7 @@ $user['role'], @@ -59,6 +60,17 @@ case 'auth/login': ]); } + qdb_discard($tmpDb, $lockFp); + + [$pdoW, $tmpDbW, $lockFpW] = qdb_open(true); + $tokenRec = [ + 'role' => $user['role'], + 'entityID' => $user['entityID'], + 'userID' => $user['userID'], + ]; + log_activity($pdoW, $tokenRec, 'login', 'user', $user['userID'], "Login: $username"); + qdb_save($tmpDbW, $lockFpW); + $token = bin2hex(random_bytes(32)); token_add($token, 30 * 24 * 60 * 60, [ 'role' => $user['role'], @@ -73,6 +85,7 @@ case 'auth/login': ]); } catch (Throwable $e) { if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); + if (isset($tmpDbW, $lockFpW)) qdb_discard($tmpDbW, $lockFpW); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Server error', 500); } @@ -137,6 +150,15 @@ case 'auth/change-password': "UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid" )->execute([':h' => $newHash, ':uid' => $user['userID']]); + log_activity( + $pdo, + array_merge($tokenRec, ['role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID']]), + 'password_changed', + 'user', + $user['userID'], + "Password changed: $username" + ); + qdb_save($tmpDb, $lockFp); // Revoke the old (possibly temp) token and issue a fresh one diff --git a/handlers/client_export.php b/handlers/client_export.php new file mode 100644 index 0000000..b869063 --- /dev/null +++ b/handlers/client_export.php @@ -0,0 +1,178 @@ +prepare($sql); +$stmt->execute($params); +$clients = $stmt->fetchAll(PDO::FETCH_ASSOC); + +if (!$exportAll && empty($clients)) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Client not found or not authorized', 404); +} + +$subStmt = $pdo->prepare(" + SELECT qs.submissionID, qs.clientCode, qs.questionnaireID, qs.submissionVersion, + qs.status, qs.startedAt, qs.completedAt, qs.uploadedAt, qs.sumPoints, + qs.submittedByRole, qn.name AS questionnaireName + FROM questionnaire_submission qs + JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID + WHERE qs.clientCode = :cc + ORDER BY qs.uploadedAt, qs.submissionVersion +"); + +$ansStmt = $pdo->prepare(" + SELECT sa.questionID, sa.answerOptionID, sa.freeTextValue, sa.numericValue, + q.defaultText AS questionText, ao.defaultText AS optionText + FROM submission_answer sa + JOIN question q ON q.questionID = sa.questionID + LEFT JOIN answer_option ao ON ao.answerOptionID = sa.answerOptionID + WHERE sa.submissionID = :sid + ORDER BY q.orderIndex +"); + +$rows = []; +foreach ($clients as $cl) { + $cc = $cl['clientCode']; + $subStmt->execute([':cc' => $cc]); + foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $sub) { + $ansStmt->execute([':sid' => $sub['submissionID']]); + $answers = $ansStmt->fetchAll(PDO::FETCH_ASSOC); + if (empty($answers)) { + $rows[] = build_export_row($cl, $sub, null); + continue; + } + foreach ($answers as $a) { + $rows[] = build_export_row($cl, $sub, $a); + } + } +} + +qdb_discard($tmpDb, $lockFp); + +try { + [$pdoW, $tmpDbW, $lockFpW] = qdb_open(true); + log_activity( + $pdoW, + $tokenRec, + 'client_data_exported', + 'client', + $exportAll ? '*' : $clientCode, + $exportAll ? 'Exported all client data' : "Exported data for $clientCode", + ['exportAll' => $exportAll, 'clientCode' => $exportAll ? null : $clientCode, 'rowCount' => count($rows)] + ); + qdb_save($tmpDbW, $lockFpW); +} catch (Throwable $e) { + if (isset($tmpDbW, $lockFpW)) qdb_discard($tmpDbW, $lockFpW); + error_log('Activity log failed on export: ' . $e->getMessage()); +} + +$filename = $exportAll + ? 'clients_export_' . date('Y-m-d') . '.csv' + : 'client_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $clientCode) . '_export.csv'; + +header('Content-Type: text/csv; charset=UTF-8'); +header('Content-Disposition: attachment; filename="' . $filename . '"'); +echo "\xEF\xBB\xBF"; + +$out = fopen('php://output', 'w'); +$headers = [ + 'clientCode', 'coach', 'supervisor', 'questionnaireID', 'questionnaireName', + 'submissionVersion', 'submissionID', 'status', 'sumPoints', + 'startedAt', 'completedAt', 'uploadedAt', 'submittedByRole', + 'questionText', 'answerOption', 'freeTextValue', 'numericValue', +]; +fputcsv($out, $headers); +foreach ($rows as $r) { + fputcsv($out, [ + $r['clientCode'], + $r['coach'], + $r['supervisor'], + $r['questionnaireID'], + $r['questionnaireName'], + $r['submissionVersion'], + $r['submissionID'], + $r['status'], + $r['sumPoints'], + $r['startedAt'], + $r['completedAt'], + $r['uploadedAt'], + $r['submittedByRole'], + $r['questionText'], + $r['answerOption'], + $r['freeTextValue'], + $r['numericValue'], + ]); +} +fclose($out); +exit; + +function build_export_row(array $cl, array $sub, ?array $a): array { + $fmt = static function ($ms) { + if ($ms === null || $ms === '') return ''; + return date('Y-m-d H:i', (int)($ms / 1000)); + }; + $answerVal = ''; + if ($a) { + if (!empty($a['optionText'])) { + $answerVal = $a['optionText']; + } elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') { + $answerVal = $a['freeTextValue']; + } elseif ($a['numericValue'] !== null) { + $answerVal = (string)$a['numericValue']; + } + } + return [ + 'clientCode' => $cl['clientCode'], + 'coach' => $cl['coachUsername'] ?? $cl['coachID'] ?? '', + 'supervisor' => $cl['supervisorUsername'] ?? '', + 'questionnaireID' => $sub['questionnaireID'], + 'questionnaireName' => $sub['questionnaireName'] ?? '', + 'submissionVersion' => (int)$sub['submissionVersion'], + 'submissionID' => $sub['submissionID'], + 'status' => $sub['status'], + 'sumPoints' => $sub['sumPoints'] !== null ? (int)$sub['sumPoints'] : '', + 'startedAt' => $fmt($sub['startedAt']), + 'completedAt' => $fmt($sub['completedAt']), + 'uploadedAt' => $fmt($sub['uploadedAt']), + 'submittedByRole' => $sub['submittedByRole'] ?? '', + 'questionText' => $a['questionText'] ?? '', + 'answerOption' => $answerVal, + 'freeTextValue' => $a['freeTextValue'] ?? '', + 'numericValue' => $a['numericValue'] !== null ? $a['numericValue'] : '', + ]; +} diff --git a/handlers/clients.php b/handlers/clients.php index 6d19187..74008a5 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -1,5 +1,7 @@ prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)") ->execute([':cc' => $clientCode, ':cid' => $coachID]); + log_activity($pdo, $tokenRec, 'client_created', 'client', $clientCode, "Created client $clientCode", ['coachID' => $coachID]); + qdb_save($tmpDb, $lockFp); json_success(['clientCode' => $clientCode, 'coachID' => $coachID]); } catch (Throwable $e) { @@ -100,6 +104,13 @@ case 'DELETE': } $pdo->beginTransaction(); + $pdo->prepare(" + DELETE FROM submission_answer WHERE submissionID IN ( + SELECT submissionID FROM questionnaire_submission WHERE clientCode = :cc + ) + ")->execute([':cc' => $clientCode]); + $pdo->prepare("DELETE FROM questionnaire_submission WHERE clientCode = :cc") + ->execute([':cc' => $clientCode]); $pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc") ->execute([':cc' => $clientCode]); $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc") @@ -108,6 +119,8 @@ case 'DELETE': ->execute([':cc' => $clientCode]); $pdo->commit(); + log_activity($pdo, $tokenRec, 'client_deleted', 'client', $clientCode, "Deleted client $clientCode"); + qdb_save($tmpDb, $lockFp); json_success([]); } catch (Throwable $e) { diff --git a/handlers/users.php b/handlers/users.php index ba1ed29..c071fb7 100644 --- a/handlers/users.php +++ b/handlers/users.php @@ -1,5 +1,7 @@ $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now, ]); $pdo->commit(); + + log_activity($pdo, $tokenRec, 'user_created', 'user', $userID, "Created $role user $username", ['role' => $role, 'entityID' => $entityID]); + qdb_save($tmpDb, $lockFp); $svUsername = null; @@ -219,6 +224,9 @@ case 'DELETE': break; } $pdo->commit(); + + log_activity($pdo, $tokenRec, 'user_deleted', 'user', $targetUserID, "Deleted {$target['role']} user", ['role' => $target['role']]); + qdb_save($tmpDb, $lockFp); json_success([]); diff --git a/lib/activity.php b/lib/activity.php new file mode 100644 index 0000000..ea8dab3 --- /dev/null +++ b/lib/activity.php @@ -0,0 +1,56 @@ +prepare('SELECT username FROM users WHERE userID = :id'); + $u->execute([':id' => $userID]); + $username = (string)($u->fetchColumn() ?: ''); + } + $pdo->prepare(" + INSERT INTO activity_event ( + eventID, createdAt, actorUserID, actorUsername, actorRole, actorEntityID, + action, targetType, targetID, summary, detailsJson + ) VALUES ( + :eid, :ca, :uid, :un, :role, :eid2, + :act, :tt, :tid, :sum, :dj + ) + ")->execute([ + ':eid' => $eventID, + ':ca' => $createdAt, + ':uid' => $userID, + ':un' => $username, + ':role' => $tokenRec['role'] ?? '', + ':eid2' => $tokenRec['entityID'] ?? '', + ':act' => $action, + ':tt' => $targetType, + ':tid' => $targetID, + ':sum' => $summary, + ':dj' => json_encode($details, JSON_UNESCAPED_UNICODE), + ]); +} + +function next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int { + $stmt = $pdo->prepare(" + SELECT COALESCE(MAX(submissionVersion), 0) + 1 + FROM questionnaire_submission + WHERE clientCode = :cc AND questionnaireID = :qn + "); + $stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]); + return (int)$stmt->fetchColumn(); +} diff --git a/lib/migrate_v4.php b/lib/migrate_v4.php new file mode 100644 index 0000000..eb5b7d9 --- /dev/null +++ b/lib/migrate_v4.php @@ -0,0 +1,81 @@ +query("PRAGMA table_info(completed_questionnaire)")->fetchAll(PDO::FETCH_ASSOC); + $hasUploaded = false; + foreach ($cols as $c) { + if (($c['name'] ?? '') === 'uploadedAt') { + $hasUploaded = true; + break; + } + } + if (!$hasUploaded) { + $pdo->exec('ALTER TABLE completed_questionnaire ADD COLUMN uploadedAt INTEGER'); + } + + $subCount = (int)$pdo->query('SELECT COUNT(*) FROM questionnaire_submission')->fetchColumn(); + if ($subCount > 0) { + return; + } + + $completions = $pdo->query('SELECT * FROM completed_questionnaire')->fetchAll(PDO::FETCH_ASSOC); + if (empty($completions)) { + return; + } + + $insertSub = $pdo->prepare(" + INSERT INTO questionnaire_submission ( + submissionID, clientCode, questionnaireID, assignedByCoach, status, + startedAt, completedAt, uploadedAt, sumPoints, submissionVersion, + submittedByUserID, submittedByRole + ) VALUES ( + :sid, :cc, :qn, :abc, :st, :sa, :ca, :ua, :sp, 1, NULL, NULL + ) + "); + $insertAns = $pdo->prepare(" + INSERT INTO submission_answer (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) + VALUES (:sid, :qid, :aoid, :ftv, :nv, :at) + "); + $updateCq = $pdo->prepare('UPDATE completed_questionnaire SET uploadedAt = :ua WHERE clientCode = :cc AND questionnaireID = :qn'); + + foreach ($completions as $cq) { + $cc = $cq['clientCode']; + $qn = $cq['questionnaireID']; + $uploadedAt = $cq['completedAt'] !== null ? (int)$cq['completedAt'] : (int)(microtime(true) * 1000); + $submissionID = 'mig_' . bin2hex(random_bytes(12)); + + $insertSub->execute([ + ':sid' => $submissionID, + ':cc' => $cc, + ':qn' => $qn, + ':abc' => $cq['assignedByCoach'] ?? null, + ':st' => $cq['status'] ?: 'completed', + ':sa' => $cq['startedAt'], + ':ca' => $cq['completedAt'], + ':ua' => $uploadedAt, + ':sp' => $cq['sumPoints'], + ]); + + $ansStmt = $pdo->prepare(" + SELECT questionID, answerOptionID, freeTextValue, numericValue, answeredAt + FROM client_answer WHERE clientCode = :cc + AND questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qn) + "); + $ansStmt->execute([':cc' => $cc, ':qn' => $qn]); + foreach ($ansStmt->fetchAll(PDO::FETCH_ASSOC) as $a) { + $insertAns->execute([ + ':sid' => $submissionID, + ':qid' => $a['questionID'], + ':aoid' => $a['answerOptionID'], + ':ftv' => $a['freeTextValue'], + ':nv' => $a['numericValue'], + ':at' => $a['answeredAt'], + ]); + } + + $updateCq->execute([':ua' => $uploadedAt, ':cc' => $cc, ':qn' => $qn]); + } +} diff --git a/schema.sql b/schema.sql index 342ed6b..e243337 100644 --- a/schema.sql +++ b/schema.sql @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS completed_questionnaire ( status TEXT NOT NULL DEFAULT '', startedAt INTEGER, completedAt INTEGER, + uploadedAt INTEGER, sumPoints INTEGER, PRIMARY KEY (clientCode, questionnaireID), FOREIGN KEY(clientCode) REFERENCES client(clientCode), @@ -93,6 +94,51 @@ CREATE TABLE IF NOT EXISTS completed_questionnaire ( FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID) ); +CREATE TABLE IF NOT EXISTS questionnaire_submission ( + submissionID TEXT NOT NULL PRIMARY KEY, + clientCode TEXT NOT NULL, + questionnaireID TEXT NOT NULL, + assignedByCoach TEXT, + status TEXT NOT NULL DEFAULT 'completed', + startedAt INTEGER, + completedAt INTEGER, + uploadedAt INTEGER NOT NULL, + sumPoints INTEGER, + submissionVersion INTEGER NOT NULL DEFAULT 1, + submittedByUserID TEXT, + submittedByRole TEXT, + FOREIGN KEY(clientCode) REFERENCES client(clientCode), + FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID), + FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID) +); + +CREATE TABLE IF NOT EXISTS submission_answer ( + submissionID TEXT NOT NULL, + questionID TEXT NOT NULL, + answerOptionID TEXT, + freeTextValue TEXT, + numericValue REAL, + answeredAt INTEGER, + PRIMARY KEY (submissionID, questionID), + FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID), + FOREIGN KEY(questionID) REFERENCES question(questionID), + FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID) +); + +CREATE TABLE IF NOT EXISTS activity_event ( + eventID TEXT NOT NULL PRIMARY KEY, + createdAt INTEGER NOT NULL, + actorUserID TEXT, + actorUsername TEXT, + actorRole TEXT, + actorEntityID TEXT, + action TEXT NOT NULL, + targetType TEXT, + targetID TEXT, + summary TEXT NOT NULL DEFAULT '', + detailsJson TEXT NOT NULL DEFAULT '{}' +); + CREATE TABLE IF NOT EXISTS question_translation ( questionID TEXT NOT NULL, languageCode TEXT NOT NULL, diff --git a/website/index.html b/website/index.html index 1ce969a..f1dbed9 100644 --- a/website/index.html +++ b/website/index.html @@ -33,6 +33,12 @@ Clients +
  • + + + Activity + +
  • diff --git a/website/js/app.js b/website/js/app.js index 107dc92..7304326 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -7,6 +7,7 @@ import { exportPage } from './pages/export.js'; import { usersPage } from './pages/users.js'; import { assignmentsPage } from './pages/assignments.js'; import { clientsPage } from './pages/clients.js'; +import { activityPage } from './pages/activity.js'; // Auth state export function isLoggedIn() { @@ -88,6 +89,7 @@ addRoute('/export', authGuard(exportPage)); addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage)); addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage)); addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage)); +addRoute('/activity', roleGuard(['admin', 'supervisor'], activityPage)); // Nav link handling & logout document.addEventListener('DOMContentLoaded', () => { diff --git a/website/js/pages/activity.js b/website/js/pages/activity.js new file mode 100644 index 0000000..78a73c7 --- /dev/null +++ b/website/js/pages/activity.js @@ -0,0 +1,104 @@ +import { apiGet } from '../api.js'; +import { showToast } from '../app.js'; + +export async function activityPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + +
    +
    +
    + + +
    + +
    +
    +
    + `; + + document.getElementById('act_refresh').addEventListener('click', loadActivity); + document.getElementById('act_filter').addEventListener('change', loadActivity); + await loadActivity(); +} + +async function loadActivity() { + const container = document.getElementById('activityContent'); + container.innerHTML = '
    '; + const action = document.getElementById('act_filter')?.value || ''; + const qs = action ? `?limit=200&action=${encodeURIComponent(action)}` : '?limit=200'; + + try { + const data = await apiGet(`activity${qs}`); + renderActivity(data.events || [], data.total ?? 0); + } catch (e) { + showToast(e.message, 'error'); + container.innerHTML = `

    ${esc(e.message)}

    `; + } +} + +function renderActivity(events, total) { + const container = document.getElementById('activityContent'); + + if (!events.length) { + container.innerHTML = ` +

    No activity

    `; + return; + } + + container.innerHTML = ` +
    +

    Showing ${events.length} of ${total} events (newest first)

    +
    + + + + + + + + + + + + + ${events.map(e => ` + + + + + + + + + `).join('')} + +
    TimeActorRoleActionTargetSummary
    ${esc(formatTime(e.createdAt))}${esc(e.actorUsername || e.actorUserID || '—')}${esc(e.actorRole || '')}${esc(e.action)}${esc(e.targetType || '')}${e.targetID ? ': ' + esc(e.targetID) : ''}${esc(e.summary)}
    +
    +
    `; +} + +function formatTime(ms) { + if (!ms) return ''; + const d = new Date(Number(ms)); + return d.toLocaleString(); +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index 628b6d1..d0e5bdd 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -11,6 +11,7 @@ export async function clientsPage() { @@ -18,6 +19,8 @@ export async function clientsPage() {
    `; + document.getElementById('exportAllBtn').addEventListener('click', () => downloadClientExport('clients/export_all')); + document.getElementById('showCreateFormBtn').addEventListener('click', () => { const wrapper = document.getElementById('createClientWrapper'); if (wrapper.style.display === 'none') { @@ -81,6 +84,35 @@ function renderClients() { container.querySelectorAll('.delete-client-btn').forEach(btn => { btn.addEventListener('click', () => deleteClient(btn.dataset.code)); }); + container.querySelectorAll('.download-client-btn').forEach(btn => { + btn.addEventListener('click', () => downloadClientExport(`clients/export?clientCode=${encodeURIComponent(btn.dataset.code)}`)); + }); +} + +async function downloadClientExport(endpoint) { + try { + const token = localStorage.getItem('qdb_token'); + const res = await fetch(`../api/${endpoint}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: { message: 'Download failed' } })); + throw new Error(err.error?.message || err.error || 'Download failed'); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename="([^"]+)"/); + const filename = match ? match[1] : 'client_export.csv'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + showToast('Download started', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } } function clientRowHTML(c) { @@ -93,6 +125,7 @@ function clientRowHTML(c) { ${esc(c.clientCode)} ${coach} + `;