@ -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',
|
||||
|
||||
@ -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;");
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
@ -5,12 +5,9 @@
|
||||
* GET ?id=<questionnaireID> -> 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/activity.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
@ -91,17 +89,6 @@ case 'POST':
|
||||
$count += $updStmt->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]);
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/activity.php';
|
||||
|
||||
switch ($route) {
|
||||
|
||||
case 'auth/login':
|
||||
@ -43,8 +41,9 @@ case 'auth/login':
|
||||
);
|
||||
}
|
||||
|
||||
if ((int)$user['mustChangePassword'] === 1) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
if ((int)$user['mustChangePassword'] === 1) {
|
||||
$tempToken = bin2hex(random_bytes(32));
|
||||
token_add($tempToken, 10 * 60, [
|
||||
'role' => $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
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
<?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'] : '',
|
||||
];
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/activity.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
@ -70,8 +68,6 @@ case 'POST':
|
||||
$pdo->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) {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/activity.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
@ -141,9 +139,6 @@ case 'POST':
|
||||
':role' => $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([]);
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
46
schema.sql
46
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,
|
||||
|
||||
@ -33,12 +33,6 @@
|
||||
Clients
|
||||
</a>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 = `
|
||||
<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;
|
||||
}
|
||||
@ -11,7 +11,6 @@ export async function clientsPage() {
|
||||
<div class="page-header">
|
||||
<h1>Clients</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -19,8 +18,6 @@ export async function clientsPage() {
|
||||
<div id="clientsContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
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) {
|
||||
<td><strong>${esc(c.clientCode)}</strong></td>
|
||||
<td>${coach}</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>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
Reference in New Issue
Block a user