updated api

This commit is contained in:
2026-05-22 13:26:29 +02:00
parent c86372af2e
commit 52d4ef42dc
17 changed files with 867 additions and 41 deletions

View File

@ -39,6 +39,9 @@ $routes = [
'users' => __DIR__ . '/../handlers/users.php', 'users' => __DIR__ . '/../handlers/users.php',
'assignments' => __DIR__ . '/../handlers/assignments.php', 'assignments' => __DIR__ . '/../handlers/assignments.php',
'clients' => __DIR__ . '/../handlers/clients.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', 'results' => __DIR__ . '/../handlers/results.php',
'export' => __DIR__ . '/../handlers/export.php', 'export' => __DIR__ . '/../handlers/export.php',
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',

View File

@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php';
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); 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 * 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) { if ($currentVersion < QDB_VERSION) {
$pdo->exec("PRAGMA foreign_keys = OFF;"); $pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->exec($sql); $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 user_version = " . QDB_VERSION . ";");
$pdo->exec("PRAGMA foreign_keys = ON;"); $pdo->exec("PRAGMA foreign_keys = ON;");
} }

View File

@ -254,7 +254,8 @@ Fields:
- `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none. - `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[].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[].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: Common failures:
@ -368,14 +369,16 @@ Answer fields:
- `numericValue`: for numeric/range answers. - `numericValue`: for numeric/range answers.
- `answeredAt`: timestamp for this answer. - `answeredAt`: timestamp for this answer.
Current upload behavior: Upload behavior:
- Unknown or empty `questionID` values are skipped. - Unknown or empty `questionID` values are skipped.
- Unknown `answerOptionKey` values are stored as no selected option for that question. - Unknown `answerOptionKey` values are stored as no selected option for that question.
- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer. - Each successful POST **appends** an immutable `questionnaire_submission` record (version increments per client + questionnaire).
- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum. - 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. - `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: Success:
@ -384,11 +387,20 @@ Success:
"ok": true, "ok": true,
"data": { "data": {
"submitted": true, "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: Common failures:
- `400 INVALID_BODY`: request body is not valid JSON. - `400 INVALID_BODY`: request body is not valid JSON.

91
handlers/activity.php Normal file
View File

@ -0,0 +1,91 @@
<?php
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$limit = min(500, max(1, (int)($_GET['limit'] ?? 100)));
$offset = max(0, (int)($_GET['offset'] ?? 0));
$since = isset($_GET['since']) ? (int)$_GET['since'] : null;
$until = isset($_GET['until']) ? (int)$_GET['until'] : null;
$action = trim($_GET['action'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
$where = ['1=1'];
$params = [];
if ($role === 'supervisor') {
$where[] = "(
(ae.actorRole = 'coach' AND ae.actorEntityID IN (
SELECT coachID FROM coach WHERE supervisorID = :sup_id
))
OR (ae.targetType = 'client' AND ae.targetID IN (
SELECT clientCode FROM client WHERE coachID IN (
SELECT coachID FROM coach WHERE supervisorID = :sup_id2
)
))
OR (ae.actorRole = 'supervisor' AND ae.actorEntityID = :sup_self)
)";
$params[':sup_id'] = $entityID;
$params[':sup_id2'] = $entityID;
$params[':sup_self'] = $entityID;
}
if ($since !== null) {
$where[] = 'ae.createdAt >= :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,
]);

View File

@ -5,9 +5,12 @@
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format * GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> all translations merged across all tables * GET ?translations=1 -> all translations merged across all tables
* GET ?clients=1 -> list of clients assigned to the authenticated coach * 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) * POST -> submit interview answers for a client (coach / supervisor / admin)
*/ */
require_once __DIR__ . '/../lib/activity.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
if ($method === 'POST') { 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(); $pdo->beginTransaction();
$sumPoints = 0; $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(" $answerInsert = $pdo->prepare("
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
@ -91,46 +159,34 @@ if ($method === 'POST') {
answeredAt = excluded.answeredAt answeredAt = excluded.answeredAt
"); ");
foreach ($answers as $a) { foreach ($parsedAnswers as $pa) {
$shortId = trim($a['questionID'] ?? ''); $subAnsInsert->execute([
if ($shortId === '') continue; ':sid' => $submissionID,
':qid' => $pa['fullQID'],
// Resolve short ID to full questionID ':aoid' => $pa['answerOptionID'],
$fullQID = $shortIdMap[$shortId] ?? null; ':ftv' => $pa['freeTextValue'],
if ($fullQID === null) continue; // skip unknown questions ':nv' => $pa['numericValue'],
':at' => $pa['answeredAt'],
$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([ $answerInsert->execute([
':cc' => $clientCode, ':cc' => $clientCode,
':qid' => $fullQID, ':qid' => $pa['fullQID'],
':aoid' => $answerOptionID, ':aoid' => $pa['answerOptionID'],
':ftv' => $freeTextValue, ':ftv' => $pa['freeTextValue'],
':nv' => $numericValue, ':nv' => $pa['numericValue'],
':at' => $answeredAt, ':at' => $pa['answeredAt'],
]); ]);
} }
// Upsert completed_questionnaire record
$pdo->prepare(" $pdo->prepare("
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, uploadedAt, sumPoints)
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp) VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :ua, :sp)
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET
assignedByCoach = excluded.assignedByCoach, assignedByCoach = excluded.assignedByCoach,
status = 'completed', status = 'completed',
startedAt = COALESCE(excluded.startedAt, startedAt), startedAt = COALESCE(excluded.startedAt, startedAt),
completedAt = excluded.completedAt, completedAt = excluded.completedAt,
uploadedAt = excluded.uploadedAt,
sumPoints = excluded.sumPoints sumPoints = excluded.sumPoints
")->execute([ ")->execute([
':cc' => $clientCode, ':cc' => $clientCode,
@ -138,13 +194,39 @@ if ($method === 'POST') {
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null, ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
':sa' => $startedAt, ':sa' => $startedAt,
':ca' => $completedAt, ':ca' => $completedAt,
':ua' => $uploadedAt,
':sp' => $sumPoints, ':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(); $pdo->commit();
qdb_save($tmpDb, $lockFp); 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) { } catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
@ -159,9 +241,80 @@ if ($method !== 'GET') {
[$pdo, $tmpDb, $lockFp] = qdb_open(false); [$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qnID = $_GET['id'] ?? ''; $qnID = trim($_GET['id'] ?? $_GET['questionnaireID'] ?? '');
$translations = $_GET['translations'] ?? ''; $translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? ''; $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) { if ($fetchClients) {
require_role(['coach'], $tokenRec); require_role(['coach'], $tokenRec);
@ -181,7 +334,7 @@ if ($fetchClients) {
if (!empty($clientCodes)) { if (!empty($clientCodes)) {
$placeholders = implode(',', array_fill(0, count($clientCodes), '?')); $placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
$stmt2 = $pdo->prepare(" $stmt2 = $pdo->prepare("
SELECT clientCode, questionnaireID, sumPoints, completedAt SELECT clientCode, questionnaireID, sumPoints, completedAt, uploadedAt
FROM completed_questionnaire FROM completed_questionnaire
WHERE clientCode IN ($placeholders) WHERE clientCode IN ($placeholders)
"); ");
@ -191,6 +344,7 @@ if ($fetchClients) {
'questionnaireID' => $row['questionnaireID'], 'questionnaireID' => $row['questionnaireID'],
'sumPoints' => (int) $row['sumPoints'], 'sumPoints' => (int) $row['sumPoints'],
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null, 'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
'uploadedAt' => $row['uploadedAt'] !== null ? (int) $row['uploadedAt'] : null,
]; ];
} }
} }

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/activity.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
@ -89,6 +91,17 @@ case 'POST':
$count += $updStmt->rowCount(); $count += $updStmt->rowCount();
} }
$pdo->commit(); $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); qdb_save($tmpDb, $lockFp);
json_success(['assigned' => $count]); json_success(['assigned' => $count]);

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/activity.php';
switch ($route) { switch ($route) {
case 'auth/login': case 'auth/login':
@ -41,9 +43,8 @@ case 'auth/login':
); );
} }
qdb_discard($tmpDb, $lockFp);
if ((int)$user['mustChangePassword'] === 1) { if ((int)$user['mustChangePassword'] === 1) {
qdb_discard($tmpDb, $lockFp);
$tempToken = bin2hex(random_bytes(32)); $tempToken = bin2hex(random_bytes(32));
token_add($tempToken, 10 * 60, [ token_add($tempToken, 10 * 60, [
'role' => $user['role'], 'role' => $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 = bin2hex(random_bytes(32));
token_add($token, 30 * 24 * 60 * 60, [ token_add($token, 30 * 24 * 60 * 60, [
'role' => $user['role'], 'role' => $user['role'],
@ -73,6 +85,7 @@ case 'auth/login':
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
if (isset($tmpDbW, $lockFpW)) qdb_discard($tmpDbW, $lockFpW);
error_log($e->getMessage()); error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500); 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" "UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
)->execute([':h' => $newHash, ':uid' => $user['userID']]); )->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); qdb_save($tmpDb, $lockFp);
// Revoke the old (possibly temp) token and issue a fresh one // Revoke the old (possibly temp) token and issue a fresh one

178
handlers/client_export.php Normal file
View File

@ -0,0 +1,178 @@
<?php
require_once __DIR__ . '/../lib/activity.php';
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$clientCode = trim($_GET['clientCode'] ?? '');
$exportAll = (strpos($route, 'export_all') !== false) || isset($_GET['export_all']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clientFilter = '';
$params = $rbacParams;
if (!$exportAll) {
if ($clientCode === '') {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_PARAM', 'clientCode query param required', 400);
}
$clientFilter = ' AND cl.clientCode = :cc';
$params[':cc'] = $clientCode;
}
$sql = "
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 ($rbacClause) $clientFilter
ORDER BY cl.clientCode
";
$stmt = $pdo->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'] : '',
];
}

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/activity.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
@ -68,6 +70,8 @@ case 'POST':
$pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)") $pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)")
->execute([':cc' => $clientCode, ':cid' => $coachID]); ->execute([':cc' => $clientCode, ':cid' => $coachID]);
log_activity($pdo, $tokenRec, 'client_created', 'client', $clientCode, "Created client $clientCode", ['coachID' => $coachID]);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]); json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
} catch (Throwable $e) { } catch (Throwable $e) {
@ -100,6 +104,13 @@ case 'DELETE':
} }
$pdo->beginTransaction(); $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") $pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc")
->execute([':cc' => $clientCode]); ->execute([':cc' => $clientCode]);
$pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc") $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc")
@ -108,6 +119,8 @@ case 'DELETE':
->execute([':cc' => $clientCode]); ->execute([':cc' => $clientCode]);
$pdo->commit(); $pdo->commit();
log_activity($pdo, $tokenRec, 'client_deleted', 'client', $clientCode, "Deleted client $clientCode");
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([]); json_success([]);
} catch (Throwable $e) { } catch (Throwable $e) {

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/activity.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
@ -139,6 +141,9 @@ case 'POST':
':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now, ':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now,
]); ]);
$pdo->commit(); $pdo->commit();
log_activity($pdo, $tokenRec, 'user_created', 'user', $userID, "Created $role user $username", ['role' => $role, 'entityID' => $entityID]);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
$svUsername = null; $svUsername = null;
@ -219,6 +224,9 @@ case 'DELETE':
break; break;
} }
$pdo->commit(); $pdo->commit();
log_activity($pdo, $tokenRec, 'user_deleted', 'user', $targetUserID, "Deleted {$target['role']} user", ['role' => $target['role']]);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([]); json_success([]);

56
lib/activity.php Normal file
View File

@ -0,0 +1,56 @@
<?php
/**
* Activity / audit log helpers.
*/
function log_activity(
PDO $pdo,
array $tokenRec,
string $action,
string $targetType,
string $targetID,
string $summary,
array $details = [],
?int $createdAt = null
): void {
$createdAt = $createdAt ?? (int)(microtime(true) * 1000);
$eventID = 'evt_' . bin2hex(random_bytes(12));
$userID = $tokenRec['userID'] ?? '';
$username = '';
if ($userID !== '') {
$u = $pdo->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();
}

81
lib/migrate_v4.php Normal file
View File

@ -0,0 +1,81 @@
<?php
/**
* One-time migration to v4: submission history, uploadedAt, activity tables.
*/
function qdb_migrate_v4(PDO $pdo): void {
$cols = $pdo->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]);
}
}

View File

@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS completed_questionnaire (
status TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '',
startedAt INTEGER, startedAt INTEGER,
completedAt INTEGER, completedAt INTEGER,
uploadedAt INTEGER,
sumPoints INTEGER, sumPoints INTEGER,
PRIMARY KEY (clientCode, questionnaireID), PRIMARY KEY (clientCode, questionnaireID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode), FOREIGN KEY(clientCode) REFERENCES client(clientCode),
@ -93,6 +94,51 @@ CREATE TABLE IF NOT EXISTS completed_questionnaire (
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID) 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 ( CREATE TABLE IF NOT EXISTS question_translation (
questionID TEXT NOT NULL, questionID TEXT NOT NULL,
languageCode TEXT NOT NULL, languageCode TEXT NOT NULL,

View File

@ -33,6 +33,12 @@
Clients Clients
</a> </a>
</li> </li>
<li data-nav-roles="admin supervisor">
<a href="#/activity">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Activity
</a>
</li>
<li data-nav-roles="admin supervisor"> <li data-nav-roles="admin supervisor">
<a href="#/assignments"> <a href="#/assignments">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>

View File

@ -7,6 +7,7 @@ import { exportPage } from './pages/export.js';
import { usersPage } from './pages/users.js'; import { usersPage } from './pages/users.js';
import { assignmentsPage } from './pages/assignments.js'; import { assignmentsPage } from './pages/assignments.js';
import { clientsPage } from './pages/clients.js'; import { clientsPage } from './pages/clients.js';
import { activityPage } from './pages/activity.js';
// Auth state // Auth state
export function isLoggedIn() { export function isLoggedIn() {
@ -88,6 +89,7 @@ addRoute('/export', authGuard(exportPage));
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage)); addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage)); addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage)); addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
addRoute('/activity', roleGuard(['admin', 'supervisor'], activityPage));
// Nav link handling & logout // Nav link handling & logout
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@ -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 = `
<div class="page-header">
<h1>Activity</h1>
</div>
<div class="card" style="margin-bottom:16px">
<div class="form-row" style="align-items:flex-end">
<div class="form-group">
<label>Action</label>
<select id="act_filter">
<option value="">All actions</option>
<option value="login">login</option>
<option value="questionnaire_submitted">questionnaire_submitted</option>
<option value="client_created">client_created</option>
<option value="client_deleted">client_deleted</option>
<option value="clients_assigned">clients_assigned</option>
<option value="client_data_exported">client_data_exported</option>
<option value="user_created">user_created</option>
<option value="user_deleted">user_deleted</option>
<option value="password_changed">password_changed</option>
</select>
</div>
<button class="btn btn-primary btn-sm" id="act_refresh">Refresh</button>
</div>
</div>
<div id="activityContent"><div class="spinner"></div></div>
`;
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 = '<div class="spinner"></div>';
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 = `<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function renderActivity(events, total) {
const container = document.getElementById('activityContent');
if (!events.length) {
container.innerHTML = `
<div class="card"><div class="empty-state"><h3>No activity</h3></div></div>`;
return;
}
container.innerHTML = `
<div class="card">
<p style="margin-bottom:12px;color:var(--text-secondary)">Showing ${events.length} of ${total} events (newest first)</p>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Role</th>
<th>Action</th>
<th>Target</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
${events.map(e => `
<tr>
<td>${esc(formatTime(e.createdAt))}</td>
<td>${esc(e.actorUsername || e.actorUserID || '—')}</td>
<td>${esc(e.actorRole || '')}</td>
<td><span class="badge">${esc(e.action)}</span></td>
<td>${esc(e.targetType || '')}${e.targetID ? ': ' + esc(e.targetID) : ''}</td>
<td>${esc(e.summary)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>`;
}
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;
}

View File

@ -11,6 +11,7 @@ export async function clientsPage() {
<div class="page-header"> <div class="page-header">
<h1>Clients</h1> <h1>Clients</h1>
<div class="actions"> <div class="actions">
<button class="btn btn-sm" id="exportAllBtn">Download all client data</button>
<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button> <button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>
</div> </div>
</div> </div>
@ -18,6 +19,8 @@ export async function clientsPage() {
<div id="clientsContent"><div class="spinner"></div></div> <div id="clientsContent"><div class="spinner"></div></div>
`; `;
document.getElementById('exportAllBtn').addEventListener('click', () => downloadClientExport('clients/export_all'));
document.getElementById('showCreateFormBtn').addEventListener('click', () => { document.getElementById('showCreateFormBtn').addEventListener('click', () => {
const wrapper = document.getElementById('createClientWrapper'); const wrapper = document.getElementById('createClientWrapper');
if (wrapper.style.display === 'none') { if (wrapper.style.display === 'none') {
@ -81,6 +84,35 @@ function renderClients() {
container.querySelectorAll('.delete-client-btn').forEach(btn => { container.querySelectorAll('.delete-client-btn').forEach(btn => {
btn.addEventListener('click', () => deleteClient(btn.dataset.code)); 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) { function clientRowHTML(c) {
@ -93,6 +125,7 @@ function clientRowHTML(c) {
<td><strong>${esc(c.clientCode)}</strong></td> <td><strong>${esc(c.clientCode)}</strong></td>
<td>${coach}</td> <td>${coach}</td>
<td> <td>
<button class="btn btn-sm download-client-btn" data-code="${esc(c.clientCode)}">Download</button>
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button> <button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
</td> </td>
</tr>`; </tr>`;