From c86768e39140b9d045fce57a08526a887c496b6e Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 22 May 2026 12:27:12 +0000 Subject: [PATCH] revert 52d4ef42dcc3e80d14d7dbaf21100d1367743093 revert 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, 41 insertions(+), 867 deletions(-) delete mode 100644 handlers/activity.php delete mode 100644 handlers/client_export.php delete mode 100644 lib/activity.php delete mode 100644 lib/migrate_v4.php delete mode 100644 website/js/pages/activity.js diff --git a/api/index.php b/api/index.php index 995eced..37852ee 100644 --- a/api/index.php +++ b/api/index.php @@ -39,9 +39,6 @@ $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 ef81129..54a4266 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', 4); +define('QDB_VERSION', 3); /** * Decrypt the master DB file into a temp SQLite file, or create a fresh one @@ -72,10 +72,6 @@ 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 4d3ca63..67f3200 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -254,8 +254,7 @@ 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`: 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`. +- `clients[].completedQuestionnaires[].completedAt`: Unix timestamp (seconds) of when the questionnaire was completed, or `null` if not recorded. Common failures: @@ -369,16 +368,14 @@ Answer fields: - `numericValue`: for numeric/range answers. - `answeredAt`: timestamp for this answer. -Upload behavior: +Current upload behavior: - Unknown or empty `questionID` values are skipped. - Unknown `answerOptionKey` values are stored as no selected option for that question. -- 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`. +- 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. - `sumPoints` is calculated from recognized `answerOptionKey` values. -- Coaches may re-open, edit, and POST again; each upload creates a new submission version. +- 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. Success: @@ -387,20 +384,11 @@ Success: "ok": true, "data": { "submitted": true, - "sumPoints": 0, - "submissionID": "sub_…", - "uploadedAt": 1714471800123, - "submissionVersion": 2 + "sumPoints": 0 } } ``` -## 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 deleted file mode 100644 index 367d50e..0000000 --- a/handlers/activity.php +++ /dev/null @@ -1,91 +0,0 @@ -= :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 0f70697..9dbc26b 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -5,12 +5,9 @@ * 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') { @@ -81,74 +78,9 @@ 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) @@ -159,34 +91,46 @@ if ($method === 'POST') { answeredAt = excluded.answeredAt "); - foreach ($parsedAnswers as $pa) { - $subAnsInsert->execute([ - ':sid' => $submissionID, - ':qid' => $pa['fullQID'], - ':aoid' => $pa['answerOptionID'], - ':ftv' => $pa['freeTextValue'], - ':nv' => $pa['numericValue'], - ':at' => $pa['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']; + } + $answerInsert->execute([ ':cc' => $clientCode, - ':qid' => $pa['fullQID'], - ':aoid' => $pa['answerOptionID'], - ':ftv' => $pa['freeTextValue'], - ':nv' => $pa['numericValue'], - ':at' => $pa['answeredAt'], + ':qid' => $fullQID, + ':aoid' => $answerOptionID, + ':ftv' => $freeTextValue, + ':nv' => $numericValue, + ':at' => $answeredAt, ]); } + // Upsert completed_questionnaire record $pdo->prepare(" - INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, uploadedAt, sumPoints) - VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :ua, :sp) + INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) + VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :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, @@ -194,39 +138,13 @@ 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, - 'submissionID' => $submissionID, - 'uploadedAt' => $uploadedAt, - 'submissionVersion' => $submissionVersion, - ]); + json_success(['submitted' => true, 'sumPoints' => $sumPoints]); } catch (Throwable $e) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); @@ -241,80 +159,9 @@ if ($method !== 'GET') { [$pdo, $tmpDb, $lockFp] = qdb_open(false); -$qnID = trim($_GET['id'] ?? $_GET['questionnaireID'] ?? ''); +$qnID = $_GET['id'] ?? ''; $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); @@ -334,7 +181,7 @@ if ($fetchClients) { if (!empty($clientCodes)) { $placeholders = implode(',', array_fill(0, count($clientCodes), '?')); $stmt2 = $pdo->prepare(" - SELECT clientCode, questionnaireID, sumPoints, completedAt, uploadedAt + SELECT clientCode, questionnaireID, sumPoints, completedAt FROM completed_questionnaire WHERE clientCode IN ($placeholders) "); @@ -344,7 +191,6 @@ 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 6b63f30..40149fa 100644 --- a/handlers/assignments.php +++ b/handlers/assignments.php @@ -1,7 +1,5 @@ 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 68dfa16..cff7db5 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -1,7 +1,5 @@ $user['role'], @@ -60,17 +59,6 @@ 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'], @@ -85,7 +73,6 @@ 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); } @@ -150,15 +137,6 @@ 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 deleted file mode 100644 index b869063..0000000 --- a/handlers/client_export.php +++ /dev/null @@ -1,178 +0,0 @@ -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 74008a5..6d19187 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -1,7 +1,5 @@ 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) { @@ -104,13 +100,6 @@ 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") @@ -119,8 +108,6 @@ 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 c071fb7..ba1ed29 100644 --- a/handlers/users.php +++ b/handlers/users.php @@ -1,7 +1,5 @@ $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; @@ -224,9 +219,6 @@ 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 deleted file mode 100644 index ea8dab3..0000000 --- a/lib/activity.php +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index eb5b7d9..0000000 --- a/lib/migrate_v4.php +++ /dev/null @@ -1,81 +0,0 @@ -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 e243337..342ed6b 100644 --- a/schema.sql +++ b/schema.sql @@ -86,7 +86,6 @@ 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), @@ -94,51 +93,6 @@ 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 f1dbed9..1ce969a 100644 --- a/website/index.html +++ b/website/index.html @@ -33,12 +33,6 @@ Clients -
  • - - - Activity - -
  • diff --git a/website/js/app.js b/website/js/app.js index 7304326..107dc92 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -7,7 +7,6 @@ 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() { @@ -89,7 +88,6 @@ 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 deleted file mode 100644 index 78a73c7..0000000 --- a/website/js/pages/activity.js +++ /dev/null @@ -1,104 +0,0 @@ -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 d0e5bdd..628b6d1 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -11,7 +11,6 @@ export async function clientsPage() { @@ -19,8 +18,6 @@ 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') { @@ -84,35 +81,6 @@ 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) { @@ -125,7 +93,6 @@ function clientRowHTML(c) { ${esc(c.clientCode)} ${coach} - `;