new system prototype

This commit is contained in:
2026-04-15 10:19:42 +02:00
parent e805f225bc
commit 034b108c7e
80 changed files with 12212 additions and 890 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Secrets and session data
tokens.jsonl
valid_tokens.txt
.env
# Encrypted database, temp files, backups
uploads/
# OS / editor
.DS_Store
Thumbs.db
*.swp
*.swo
*~

6
api/.htaccess Normal file
View File

@ -0,0 +1,6 @@
RewriteEngine On
# Let the front controller handle everything except itself
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !=/api/index.php
RewriteRule ^(.*)$ index.php [QSA,L]

185
api/answer_options.php Normal file
View File

@ -0,0 +1,185 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
$qID = $_GET['questionID'] ?? '';
if (!$qID) {
http_response_code(400);
echo json_encode(["error" => "questionID query param required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare("
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$stmt->execute([':qid' => $qID]);
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($options as &$o) {
$o['points'] = (int)$o['points'];
$o['orderIndex'] = (int)$o['orderIndex'];
$tr = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
$tr->execute([':id' => $o['answerOptionID']]);
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($o);
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "answerOptions" => $options]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionID']) || !isset($body['defaultText'])) {
http_response_code(400);
echo json_encode(["error" => "questionID and defaultText required"]);
exit;
}
$id = bin2hex(random_bytes(16));
$qID = $body['questionID'];
$text = trim($body['defaultText']);
$points = (int)($body['points'] ?? 0);
$order = (int)($body['orderIndex'] ?? 0);
$nextQ = trim($body['nextQuestionId'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$chk = $pdo->prepare("SELECT 1 FROM question WHERE questionID = :id");
$chk->execute([':id' => $qID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Question not found"]);
exit;
}
if ($order === 0) {
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id");
$max->execute([':id' => $qID]);
$order = (int)$max->fetchColumn();
}
$pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :t, :p, :o, :nq)")
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "answerOption" => [
"answerOptionID" => $id, "questionID" => $qID, "defaultText" => $text,
"points" => $points, "orderIndex" => $order, "nextQuestionId" => $nextQ, "translations" => []
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['answerOptionID'])) {
http_response_code(400);
echo json_encode(["error" => "answerOptionID is required"]);
exit;
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare("SELECT * FROM answer_option WHERE answerOptionID = :id");
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Answer option not found"]);
exit;
}
$text = trim($body['defaultText'] ?? $row['defaultText']);
$points = (int)($body['points'] ?? $row['points']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
$pdo->prepare("UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id")
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "answerOption" => [
"answerOptionID" => $id, "questionID" => $row['questionID'],
"defaultText" => $text, "points" => $points, "orderIndex" => $order,
"nextQuestionId" => $nextQ
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['answerOptionID'])) {
http_response_code(400);
echo json_encode(["error" => "answerOptionID is required"]);
exit;
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $id]);
$pdo->prepare("UPDATE client_answer SET answerOptionID = NULL WHERE answerOptionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM answer_option WHERE answerOptionID = :id")->execute([':id' => $id]);
$pdo->commit();
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionID']) || !is_array($body['order'] ?? null)) {
http_response_code(400);
echo json_encode(["error" => "questionID and order[] required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$stmt = $pdo->prepare("UPDATE answer_option SET orderIndex = :o WHERE answerOptionID = :id AND questionID = :qid");
foreach ($body['order'] as $idx => $aoid) {
$stmt->execute([':o' => $idx, ':id' => $aoid, ':qid' => $body['questionID']]);
}
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
default:
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
}

174
api/app_questionnaires.php Normal file
View File

@ -0,0 +1,174 @@
<?php
/**
* Android app API endpoint.
*
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> all translations merged across all tables
*/
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
if ($translations) {
// Return all translations merged from all three tables, keyed by language
$result = [];
// question translations: map questionID -> defaultText (the key), then lang -> text
$rows = $pdo->query("
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
FROM question_translation qt
JOIN question q ON q.questionID = qt.questionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
// answer option translations: map answerOptionID -> defaultText (the key)
$rows = $pdo->query("
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
FROM answer_option_translation aot
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
// string translations (symptoms, hints, textKeys, etc.)
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "translations" => $result]);
exit;
}
if ($qnID) {
// Single questionnaire in app JSON format
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Questionnaire not found"]);
exit;
}
$stmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, configJson
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$stmt->execute([':id' => $qnID]);
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questions = [];
foreach ($dbQuestions as $dbQ) {
$localId = $dbQ['questionID'];
$parts = explode('__', $localId);
$shortId = end($parts);
$layout = $dbQ['type'];
$config = json_decode($dbQ['configJson'], true) ?: [];
$q = [
'id' => $shortId,
'layout' => $layout,
'question' => $dbQ['defaultText'],
];
// Add type-specific config fields back to the top level
if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
if (isset($config['hint'])) $q['hint'] = $config['hint'];
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if (isset($config['range'])) $q['range'] = $config['range'];
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
// String spinner static options
if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options'];
}
// Value spinner conditional navigation options
if ($layout === 'value_spinner' && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions'];
}
// Answer options (for radio_question, multi_check_box_question, etc.)
$ao = $pdo->prepare("
SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$ao->execute([':qid' => $dbQ['questionID']]);
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
if ($opts) {
$optionsArr = [];
$pointsMap = [];
foreach ($opts as $opt) {
$o = ['key' => $opt['defaultText']];
if ($opt['nextQuestionId'] !== '') {
$o['nextQuestionId'] = $opt['nextQuestionId'];
}
$optionsArr[] = $o;
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
}
$q['options'] = $optionsArr;
$hasNonZero = false;
foreach ($pointsMap as $v) { if ($v !== 0) { $hasNonZero = true; break; } }
$q['pointsMap'] = $pointsMap;
}
$questions[] = $q;
}
qdb_discard($tmpDb, $lockFp);
echo json_encode([
"meta" => ["id" => $qnID],
"questions" => $questions,
]);
exit;
}
// Default: ordered questionnaire list
$rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$list = [];
foreach ($rows as $r) {
$list[] = [
'id' => $r['questionnaireID'],
'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'],
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
];
}
qdb_discard($tmpDb, $lockFp);
echo json_encode($list);

117
api/assignments.php Normal file
View File

@ -0,0 +1,117 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
// ── GET: coaches + clients scoped by RBAC ────────────────────────────────
if ($method === 'GET') {
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($callerRole === 'admin') {
$coaches = $pdo->query(
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY sv.username, c.username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$stmt = $pdo->prepare(
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
WHERE c.supervisorID = :sid ORDER BY c.username"
);
$stmt->execute([':sid' => $callerEntityID]);
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
[$clause, $params] = rbac_client_filter($tokenRec);
$clientStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.coachID, cl.clientCode"
);
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
$clientStmt->execute();
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "coaches" => $coaches, "clients" => $clients]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
exit;
}
// ── POST: assign clients to a coach ──────────────────────────────────────
if ($method === 'POST') {
$in = json_decode(file_get_contents('php://input'), true) ?: [];
$clientCodes = $in['clientCodes'] ?? [];
$coachID = trim($in['coachID'] ?? '');
if (empty($clientCodes) || $coachID === '') {
http_response_code(400);
echo json_encode(["error" => "clientCodes and coachID required"]);
exit;
}
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
if ($callerRole === 'supervisor') {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chk->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
} else {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chk->execute([':cid' => $coachID]);
}
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(400);
echo json_encode(["error" => "Coach not found or not authorized"]);
exit;
}
// Only allow reassignment of clients the caller can already see
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$pdo->beginTransaction();
$updStmt = $pdo->prepare(
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
);
$count = 0;
foreach ($clientCodes as $cc) {
$cc = trim($cc);
if ($cc === '') continue;
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
$count += $updStmt->rowCount();
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "assigned" => $count]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
exit;
}
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);

138
api/export.php Normal file
View File

@ -0,0 +1,138 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
$tokenRec = require_valid_token();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
header('Content-Type: application/json; charset=UTF-8');
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
exit;
}
$qnID = $_GET['questionnaireID'] ?? '';
$format = strtolower($_GET['format'] ?? 'csv');
if (!$qnID) {
header('Content-Type: application/json; charset=UTF-8');
http_response_code(400);
echo json_encode(["error" => "questionnaireID query param required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
// Questionnaire metadata
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
header('Content-Type: application/json; charset=UTF-8');
http_response_code(404);
echo json_encode(["error" => "Questionnaire not found"]);
exit;
}
// Questions ordered
$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
$qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
// Build answer-option lookup for resolving display text
$optionTextMap = [];
foreach ($questionIDs as $qid) {
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid");
$aoStmt->execute([':qid' => $qid]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
}
}
// RBAC filter
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT cl.clientCode, cl.coachID,
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
WHERE $rbacClause
ORDER BY cl.clientCode
";
$params = array_merge([':qnid' => $qnID], $rbacParams);
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
// Fetch answers for each client
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
$answerStmt = $pdo->prepare("
SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
");
$rows = [];
foreach ($clients as $c) {
$row = [
'clientCode' => $c['clientCode'],
'coachID' => $c['coachID'],
'status' => $c['status'],
'sumPoints' => $c['sumPoints'],
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
'completedAt'=> $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
];
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
$answerMap = [];
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
foreach ($questions as $q) {
$qid = $q['questionID'];
if (isset($answerMap[$qid])) {
$a = $answerMap[$qid];
if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) {
$row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']];
} elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') {
$row[$q['defaultText']] = $a['freeTextValue'];
} elseif ($a['numericValue'] !== null) {
$row[$q['defaultText']] = $a['numericValue'];
} else {
$row[$q['defaultText']] = '';
}
} else {
$row[$q['defaultText']] = '';
}
}
$rows[] = $row;
}
qdb_discard($tmpDb, $lockFp);
// Output CSV
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
// BOM for Excel UTF-8 recognition
fwrite($out, "\xEF\xBB\xBF");
if (!empty($rows)) {
fputcsv($out, array_keys($rows[0]));
foreach ($rows as $row) {
fputcsv($out, array_values($row));
}
} else {
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) $header[] = $q['defaultText'];
fputcsv($out, $header);
}
fclose($out);

66
api/index.php Normal file
View File

@ -0,0 +1,66 @@
<?php
/**
* Front-controller: single entry point for all /api/ requests.
* Handles CORS, JSON content type, global error catching, and dispatch.
*/
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
require_once __DIR__ . '/../lib/response.php';
require_once __DIR__ . '/../lib/validate.php';
// CORS
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
header('Content-Type: application/json; charset=UTF-8');
// Parse route: strip /api/ prefix and .php suffix, normalize
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$path = parse_url($requestUri, PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
$route = trim(substr($path, strlen($base)), '/');
$route = preg_replace('/\.php$/', '', $route);
$route = strtolower($route);
$method = $_SERVER['REQUEST_METHOD'];
// Dispatch table: route -> handler file
$routes = [
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
'questions' => __DIR__ . '/../handlers/questions.php',
'answer_options' => __DIR__ . '/../handlers/answer_options.php',
'translations' => __DIR__ . '/../handlers/translations.php',
'users' => __DIR__ . '/../handlers/users.php',
'assignments' => __DIR__ . '/../handlers/assignments.php',
'results' => __DIR__ . '/../handlers/results.php',
'export' => __DIR__ . '/../handlers/export.php',
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
'logout' => __DIR__ . '/../handlers/logout.php',
'auth/login' => __DIR__ . '/../handlers/auth.php',
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
'backup' => __DIR__ . '/../handlers/backup.php',
'download' => __DIR__ . '/../handlers/download.php',
];
try {
if (!isset($routes[$route])) {
json_error('NOT_FOUND', "Unknown endpoint: $route", 404);
}
$handlerFile = $routes[$route];
if (!file_exists($handlerFile)) {
json_error('NOT_FOUND', "Handler not found for: $route", 404);
}
require $handlerFile;
} catch (Throwable $e) {
error_log("Unhandled error on $method $route: " . $e->getMessage());
json_error('SERVER_ERROR', 'Internal server error', 500);
}

21
api/logout.php Normal file
View File

@ -0,0 +1,21 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
if ($_SERVER['REQUEST_METHOD'] !== 'DELETE' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
exit;
}
$token = get_bearer_token();
if (!$token) {
http_response_code(401);
echo json_encode(["error" => "Missing Bearer token"]);
exit;
}
token_revoke($token);
echo json_encode(["success" => true]);

169
api/questionnaires.php Normal file
View File

@ -0,0 +1,169 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query("
SELECT q.questionnaireID, q.name, q.version, q.state,
q.orderIndex, q.showPoints, q.conditionJson,
COUNT(qu.questionID) AS questionCount
FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
GROUP BY q.questionnaireID
ORDER BY q.orderIndex, q.name
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['orderIndex'] = (int)$r['orderIndex'];
$r['showPoints'] = (int)$r['showPoints'];
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
}
unset($r);
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "questionnaires" => $rows]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['name'])) {
http_response_code(400);
echo json_encode(["error" => "name is required"]);
exit;
}
$id = bin2hex(random_bytes(16));
$name = trim($body['name']);
$version = trim($body['version'] ?? '');
$state = trim($body['state'] ?? 'draft');
$order = (int)($body['orderIndex'] ?? 0);
$showPts = (int)($body['showPoints'] ?? 0);
$condJson = $body['conditionJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($order === 0) {
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
}
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "questionnaire" => [
"questionnaireID" => $id, "name" => $name, "version" => $version,
"state" => $state, "orderIndex" => $order, "showPoints" => $showPts,
"conditionJson" => $condJson, "questionCount" => 0
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionnaireID'])) {
http_response_code(400);
echo json_encode(["error" => "questionnaireID is required"]);
exit;
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Questionnaire not found"]);
exit;
}
$name = trim($body['name'] ?? $row['name']);
$version = trim($body['version'] ?? $row['version']);
$state = trim($body['state'] ?? $row['state']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$showPts = (int)($body['showPoints'] ?? $row['showPoints']);
$condJson = $body['conditionJson'] ?? $row['conditionJson'];
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
orderIndex = :o, showPoints = :sp, conditionJson = :cj
WHERE questionnaireID = :id")
->execute([':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "questionnaire" => [
"questionnaireID" => $id, "name" => $name, "version" => $version, "state" => $state,
"orderIndex" => $order, "showPoints" => $showPts, "conditionJson" => $condJson
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'DELETE':
require_role(['admin'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionnaireID'])) {
http_response_code(400);
echo json_encode(["error" => "questionnaireID is required"]);
exit;
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->beginTransaction();
// Get question IDs to cascade-delete answer options and translations
$qIds = $pdo->prepare("SELECT questionID FROM question WHERE questionnaireID = :id");
$qIds->execute([':id' => $id]);
$questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($questionIDs as $qid) {
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :qid");
$aoIds->execute([':qid' => $qid]);
$optionIDs = $aoIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($optionIDs as $aoid) {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
}
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :qid")->execute([':qid' => $qid]);
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :qid")->execute([':qid' => $qid]);
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :qid")->execute([':qid' => $qid]);
}
$pdo->prepare("DELETE FROM question WHERE questionnaireID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM completed_questionnaire WHERE questionnaireID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM questionnaire WHERE questionnaireID = :id")->execute([':id' => $id]);
$pdo->commit();
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
default:
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
}

215
api/questions.php Normal file
View File

@ -0,0 +1,215 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
$qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) {
http_response_code(400);
echo json_encode(["error" => "questionnaireID query param required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare("
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
");
$stmt->execute([':qid' => $qnID]);
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex'];
$q['configJson'] = $q['configJson'] ?: '{}';
$ao = $pdo->prepare("
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
}
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($q);
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "questions" => $questions]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionnaireID']) || !isset($body['defaultText'])) {
http_response_code(400);
echo json_encode(["error" => "questionnaireID and defaultText required"]);
exit;
}
$id = bin2hex(random_bytes(16));
$qnID = $body['questionnaireID'];
$text = trim($body['defaultText']);
$type = trim($body['type'] ?? '');
$order = (int)($body['orderIndex'] ?? 0);
$req = (int)($body['isRequired'] ?? 0);
$config = $body['configJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
$chk->execute([':id' => $qnID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Questionnaire not found"]);
exit;
}
if ($order === 0) {
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM question WHERE questionnaireID = :id");
$max->execute([':id' => $qnID]);
$order = (int)$max->fetchColumn();
}
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
':o' => $order, ':r' => $req, ':cj' => $config]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "question" => [
"questionID" => $id, "questionnaireID" => $qnID, "defaultText" => $text,
"type" => $type, "orderIndex" => $order, "isRequired" => $req,
"configJson" => $config, "answerOptions" => [], "translations" => []
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionID'])) {
http_response_code(400);
echo json_encode(["error" => "questionID is required"]);
exit;
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Question not found"]);
exit;
}
$text = trim($body['defaultText'] ?? $row['defaultText']);
$type = trim($body['type'] ?? $row['type']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$req = (int)($body['isRequired'] ?? $row['isRequired']);
$config = $body['configJson'] ?? $row['configJson'];
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "question" => [
"questionID" => $id, "questionnaireID" => $row['questionnaireID'],
"defaultText" => $text, "type" => $type, "orderIndex" => $order, "isRequired" => $req,
"configJson" => $config
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionID'])) {
http_response_code(400);
echo json_encode(["error" => "questionID is required"]);
exit;
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->beginTransaction();
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id");
$aoIds->execute([':id' => $id]);
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
}
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
$pdo->commit();
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
http_response_code(400);
echo json_encode(["error" => "questionnaireID and order[] required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$stmt = $pdo->prepare("UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid");
foreach ($body['order'] as $idx => $qid) {
$stmt->execute([':o' => $idx, ':id' => $qid, ':qid' => $body['questionnaireID']]);
}
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
default:
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
}

139
api/results.php Normal file
View File

@ -0,0 +1,139 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
exit;
}
$qnID = $_GET['questionnaireID'] ?? '';
$clientCode = $_GET['clientCode'] ?? '';
if (!$qnID) {
http_response_code(400);
echo json_encode(["error" => "questionnaireID query param required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
// Questionnaire metadata
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "Questionnaire not found"]);
exit;
}
// Questions with answer options
$qStmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, isRequired
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex'];
$ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
}
}
unset($q);
// RBAC filter
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
// Build client list
if ($clientCode) {
$clientSQL = "SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND $rbacClause";
$clientParams = array_merge([':cc' => $clientCode], $rbacParams);
} else {
$clientSQL = "SELECT cl.clientCode, cl.coachID FROM client cl WHERE $rbacClause";
$clientParams = $rbacParams;
}
// Only clients that have a completed_questionnaire entry for this questionnaire
$sql = "
SELECT cl.clientCode, cl.coachID,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
WHERE $rbacClause
";
$params = array_merge([':qnid' => $qnID], $rbacParams);
if ($clientCode) {
$sql .= " AND cl.clientCode = :cc";
$params[':cc'] = $clientCode;
}
$sql .= " ORDER BY cl.clientCode";
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
// Build question ID placeholders for answer fetch
if (!empty($questionIDs) && !empty($clients)) {
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
$answerStmt = $pdo->prepare("
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
");
foreach ($clients as &$c) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
$answerMap = [];
foreach ($answers as $a) {
$answerMap[$a['questionID']] = [
'answerOptionID' => $a['answerOptionID'],
'freeTextValue' => $a['freeTextValue'],
'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null,
'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null,
];
}
$c['answers'] = $answerMap;
}
unset($c);
} else {
foreach ($clients as &$c) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
$c['answers'] = (object)[];
}
unset($c);
}
qdb_discard($tmpDb, $lockFp);
echo json_encode([
"success" => true,
"questionnaire" => $questionnaire,
"questions" => $questions,
"clients" => $clients
]);

291
api/translations.php Normal file
View File

@ -0,0 +1,291 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
$method = $_SERVER['REQUEST_METHOD'];
$validTypes = ['question', 'answer_option', 'string', 'language'];
switch ($method) {
case 'GET':
// GET ?languages=1 -> list all languages
if (isset($_GET['languages'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "languages" => $rows]);
break;
}
// GET ?questionnaireID=X -> batch: all keys + all translations for a questionnaire
$qnID = $_GET['questionnaireID'] ?? '';
if ($qnID) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
// Question keys + translations
$qStmt = $pdo->prepare("
SELECT q.questionID, q.defaultText, q.configJson
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
");
$qStmt->execute([':id' => $qnID]);
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$entries = [];
$stringKeys = [];
foreach ($dbQuestions as $dbQ) {
$entries[] = [
'key' => $dbQ['defaultText'],
'type' => 'question',
'entityId' => $dbQ['questionID'],
];
// Answer option keys
$aoStmt = $pdo->prepare("
SELECT answerOptionID, defaultText FROM answer_option
WHERE questionID = :qid ORDER BY orderIndex
");
$aoStmt->execute([':qid' => $dbQ['questionID']]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$entries[] = [
'key' => $ao['defaultText'],
'type' => 'answer_option',
'entityId' => $ao['answerOptionID'],
];
}
// String keys from configJson
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
foreach (['textKey','textKey1','textKey2','hint','hint1','hint2'] as $field) {
if (!empty($config[$field])) $stringKeys[$config[$field]] = true;
}
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
foreach ($config['symptoms'] as $s) $stringKeys[$s] = true;
}
}
foreach (array_keys($stringKeys) as $sk) {
$entries[] = [
'key' => $sk,
'type' => 'string',
'entityId' => $sk,
];
}
// Fetch all existing translations for these entities
$translations = [];
// question translations
$qIds = array_filter(array_map(fn($e) => $e['type'] === 'question' ? $e['entityId'] : null, $entries));
if ($qIds) {
$ph = implode(',', array_fill(0, count($qIds), '?'));
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
$stmt->execute(array_values($qIds));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
}
}
// answer_option translations
$aoIds = array_filter(array_map(fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null, $entries));
if ($aoIds) {
$ph = implode(',', array_fill(0, count($aoIds), '?'));
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
$stmt->execute(array_values($aoIds));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
}
}
// string translations
$sKeys = array_filter(array_map(fn($e) => $e['type'] === 'string' ? $e['entityId'] : null, $entries));
if ($sKeys) {
$ph = implode(',', array_fill(0, count($sKeys), '?'));
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
$stmt->execute(array_values($sKeys));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
}
}
// Attach translations to entries
foreach ($entries as &$e) {
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
}
unset($e);
qdb_discard($tmpDb, $lockFp);
echo json_encode([
"success" => true,
"languages" => $languages,
"entries" => $entries,
]);
break;
}
// Original single-entity fetch
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
if (!in_array($type, $validTypes, true) || (!$id && $type !== 'string')) {
http_response_code(400);
echo json_encode(["error" => "type (question|answer_option|string) and id required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($type === 'question') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
$stmt->execute([':id' => $id]);
} elseif ($type === 'answer_option') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
$stmt->execute([':id' => $id]);
} else {
if ($id) {
$stmt = $pdo->prepare("SELECT stringKey, languageCode, text FROM string_translation WHERE stringKey = :id");
$stmt->execute([':id' => $id]);
} else {
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
}
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "translations" => $rows]);
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
$type = $body['type'] ?? '';
// Language management
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
$name = trim($body['name'] ?? '');
if (!$lang) {
http_response_code(400);
echo json_encode(["error" => "languageCode required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
->execute([':lc' => $lang, ':n' => $name]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
$text = $body['text'] ?? '';
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
http_response_code(400);
echo json_encode(["error" => "type, id, and languageCode required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($type === 'question') {
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} elseif ($type === 'answer_option') {
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} else {
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
}
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = json_decode(file_get_contents('php://input'), true);
$type = $body['type'] ?? '';
// Language deletion
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
if (!$lang) {
http_response_code(400);
echo json_encode(["error" => "languageCode required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
http_response_code(400);
echo json_encode(["error" => "type, id, and languageCode required"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($type === 'question') {
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang")
->execute([':id' => $id, ':lang' => $lang]);
} elseif ($type === 'answer_option') {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang")
->execute([':id' => $id, ':lang' => $lang]);
} else {
$pdo->prepare("DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang")
->execute([':id' => $id, ':lang' => $lang]);
}
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
break;
default:
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);
}

275
api/users.php Normal file
View File

@ -0,0 +1,275 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
// ── GET: list users (+ supervisors for admin) ─────────────────────────────
if ($method === 'GET') {
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($callerRole === 'admin') {
$users = $pdo->query(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
COALESCE(a.location, s.location, '') AS location,
c.supervisorID,
sv.username AS supervisorUsername
FROM users u
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY u.role, u.username"
)->fetchAll(PDO::FETCH_ASSOC);
$supervisors = $pdo->query(
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
// Supervisor: only their coaches
$svNameStmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
$svNameStmt->execute([':svid' => $callerEntityID]);
$svName = $svNameStmt->fetchColumn() ?: '';
$stmt = $pdo->prepare(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
'' AS location, c.supervisorID, :svname AS supervisorUsername
FROM users u
JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE c.supervisorID = :svid
ORDER BY u.username"
);
$stmt->execute([':svid' => $callerEntityID, ':svname' => $svName]);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$supervisors = [];
}
qdb_discard($tmpDb, $lockFp);
echo json_encode([
"success" => true,
"users" => $users,
"supervisors" => $supervisors,
"callerRole" => $callerRole,
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
exit;
}
// ── POST: create a user ───────────────────────────────────────────────────
if ($method === 'POST') {
$in = json_decode(file_get_contents('php://input'), true) ?: [];
$username = trim($in['username'] ?? '');
$password = (string)($in['password'] ?? '');
$role = strtolower(trim($in['role'] ?? ''));
$location = trim($in['location'] ?? '');
$supervisorID = trim($in['supervisorID'] ?? '');
$mustChange = isset($in['mustChangePassword']) ? (int)(bool)$in['mustChangePassword'] : 1;
if ($username === '' || $password === '' || $role === '') {
http_response_code(400);
echo json_encode(["error" => "username, password, and role are required"]);
exit;
}
if ($callerRole === 'supervisor' && $role !== 'coach') {
http_response_code(403);
echo json_encode(["error" => "Supervisors may only create coaches"]);
exit;
}
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
http_response_code(400);
echo json_encode(["error" => "role must be admin, supervisor, or coach"]);
exit;
}
if (strlen($password) < 6) {
http_response_code(400);
echo json_encode(["error" => "Password must be at least 6 characters"]);
exit;
}
// Supervisors always create coaches under themselves
if ($callerRole === 'supervisor') {
$supervisorID = $callerEntityID;
}
if ($role === 'coach' && $supervisorID === '') {
http_response_code(400);
echo json_encode(["error" => "supervisorID is required for coaches"]);
exit;
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(409);
echo json_encode(["error" => "Username already exists"]);
exit;
}
if ($role === 'coach') {
$svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
$svCheck->execute([':sid' => $supervisorID]);
if (!$svCheck->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(400);
echo json_encode(["error" => "Supervisor not found"]);
exit;
}
if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) {
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode(["error" => "Cannot create coaches for another supervisor"]);
exit;
}
}
$entityID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
switch ($role) {
case 'admin':
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'supervisor':
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'coach':
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
break;
}
$pdo->prepare(
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)"
)->execute([
':uid' => $userID, ':u' => $username, ':hash' => $hash,
':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now,
]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
// Fetch supervisor name for response
$svUsername = null;
if ($role === 'coach') {
[$pdo2, $tmp2, $lk2] = qdb_open(false);
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
$svr->execute([':sid' => $supervisorID]);
$svUsername = $svr->fetchColumn() ?: null;
qdb_discard($tmp2, $lk2);
}
echo json_encode([
"success" => true,
"userID" => $userID,
"entityID" => $entityID,
"username" => $username,
"role" => $role,
"location" => $location,
"supervisorID" => $role === 'coach' ? $supervisorID : null,
"supervisorUsername" => $svUsername,
"mustChangePassword" => $mustChange,
"createdAt" => $now,
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
exit;
}
// ── DELETE: remove a user ─────────────────────────────────────────────────
if ($method === 'DELETE') {
$in = json_decode(file_get_contents('php://input'), true) ?: [];
$targetUserID = trim($in['userID'] ?? '');
if ($targetUserID === '') {
http_response_code(400);
echo json_encode(["error" => "userID is required"]);
exit;
}
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
http_response_code(400);
echo json_encode(["error" => "Cannot delete your own account"]);
exit;
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "User not found"]);
exit;
}
if ($callerRole === 'supervisor') {
if ($target['role'] !== 'coach') {
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode(["error" => "Supervisors can only delete coaches"]);
exit;
}
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid");
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode(["error" => "Coach is not under your supervision"]);
exit;
}
}
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM users WHERE userID = :uid")->execute([':uid' => $targetUserID]);
switch ($target['role']) {
case 'admin':
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")->execute([':id' => $target['entityID']]);
break;
case 'supervisor':
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")->execute([':id' => $target['entityID']]);
break;
case 'coach':
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")->execute([':id' => $target['entityID']]);
break;
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
exit;
}
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);

118
assign_client.php Normal file
View File

@ -0,0 +1,118 @@
<?php
// /var/www/html/assign_client.php
// GET: Returns list of coaches and clients (filtered by role)
// POST: Assigns one or more clients to a coach
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$role = $tokenRec['role'];
$entityID = $tokenRec['entityID'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
// Coaches visible to this user
if ($role === 'admin') {
$coaches = $pdo->query(
"SELECT c.coachID, c.username, c.supervisorID FROM coach c ORDER BY c.username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$stmt = $pdo->prepare(
"SELECT c.coachID, c.username, c.supervisorID FROM coach c WHERE c.supervisorID = :sid ORDER BY c.username"
);
$stmt->execute([':sid' => $entityID]);
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Clients visible to this user
[$clause, $params] = rbac_client_filter($tokenRec);
$clientStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.clientCode"
);
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
$clientStmt->execute();
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
$pdo = null;
qdb_discard($tmpDb, $lockFp);
echo json_encode(["coaches" => $coaches, "clients" => $clients]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["error" => "Server error"]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = file_get_contents("php://input");
$in = json_decode($raw, true) ?: [];
$clientCodes = $in['clientCodes'] ?? [];
$coachID = trim($in['coachID'] ?? '');
if (empty($clientCodes) || $coachID === '') {
http_response_code(400);
echo json_encode(["error" => "clientCodes and coachID required"]);
exit;
}
if (!is_array($clientCodes)) {
$clientCodes = [$clientCodes];
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
// Verify coach exists and is visible
if ($role === 'supervisor') {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chk->execute([':cid' => $coachID, ':sid' => $entityID]);
} else {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chk->execute([':cid' => $coachID]);
}
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(400);
echo json_encode(["error" => "Coach not found or not authorized"]);
exit;
}
// Only allow reassignment of clients the caller can already see
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$pdo->beginTransaction();
$updStmt = $pdo->prepare(
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
);
$count = 0;
foreach ($clientCodes as $cc) {
$cc = trim($cc);
if ($cc === '') continue;
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
$count += $updStmt->rowCount();
}
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "assigned" => $count]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["error" => "Server error"]);
}
exit;
}
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);

42
backup.php Normal file
View File

@ -0,0 +1,42 @@
<?php
// /var/www/html/backup.php
// Admin-only endpoint: creates a timestamped backup of the encrypted database.
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin'], $tokenRec);
$dbPath = QDB_PATH;
$backupDir = __DIR__ . '/uploads/backups';
if (!file_exists($dbPath)) {
http_response_code(404);
echo json_encode(["error" => "No database to back up"]);
exit;
}
if (!is_dir($backupDir)) {
if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
http_response_code(500);
echo json_encode(["error" => "Could not create backups directory"]);
exit;
}
}
$timestamp = date('Y-m-d_H-i-s');
$backupFile = $backupDir . "/questionnaire_database.$timestamp";
if (!copy($dbPath, $backupFile)) {
http_response_code(500);
echo json_encode(["error" => "Backup copy failed"]);
exit;
}
@chmod($backupFile, 0644);
echo json_encode([
"success" => true,
"message" => "Backup created",
"filename" => "questionnaire_database.$timestamp",
]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

View File

@ -1,46 +1,8 @@
<?php <?php
// /var/www/html/qdb/change_password.php // /var/www/html/change_password.php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
// dieselben Defaults wie in login.php
$USERS_DEFAULT = [
'user01' => 'pw1',
'user02' => 'pw2',
'Tmp_Daniel' => 'dummy4',
'Tmp_Johanna' => 'dummy1',
'Tmp_Anke' => 'dummy2',
'Tmp_Liliana' => 'dummy3',
'Amy_tmp' => 'dummy4',
'Brigitte_tmp' => 'dummy5',
'Extra1_tmp' => 'dummy6',
'Extra2_tmp' => 'dummy7',
'Danny' => 'pw1',
'User1' => 'pw1',
'User2' => 'pw2',
'User3' => 'pw3',
'User4' => 'pw4',
'User5' => 'pw5',
'User6' => 'pw6',
'User7' => 'pw7',
'User8' => 'pw8',
'User9' => 'pw9',
'User10' => 'pw10',
'User11' => 'pw11'
];
$STORE_FILE = __DIR__ . '/users.json';
function load_store($path) {
if (!file_exists($path)) return [];
$txt = file_get_contents($path);
$arr = json_decode($txt, true);
return is_array($arr) ? $arr : [];
}
function save_store($path, $data) {
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
$raw = file_get_contents("php://input"); $raw = file_get_contents("php://input");
$in = json_decode($raw, true) ?: []; $in = json_decode($raw, true) ?: [];
@ -48,50 +10,94 @@ $username = trim($in['username'] ?? '');
$oldPassword = (string)($in['old_password'] ?? ''); $oldPassword = (string)($in['old_password'] ?? '');
$newPassword = (string)($in['new_password'] ?? ''); $newPassword = (string)($in['new_password'] ?? '');
// Require a Bearer token (temp or normal) so password changes are bound to a session
$bearerToken = get_bearer_token();
if (!$bearerToken) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Bearer token required"]);
exit;
}
$tokenRec = token_get_record($bearerToken);
if (!$tokenRec) {
http_response_code(403);
echo json_encode(["success" => false, "message" => "Invalid or expired token"]);
exit;
}
if ($username === '' || $oldPassword === '' || $newPassword === '') { if ($username === '' || $oldPassword === '' || $newPassword === '') {
http_response_code(400); http_response_code(400);
echo json_encode(["success" => false, "message" => "Felder fehlen"]); echo json_encode(["success" => false, "message" => "Missing fields"]);
exit; exit;
} }
if (strlen($newPassword) < 6) { if (strlen($newPassword) < 6) {
http_response_code(400); http_response_code(400);
echo json_encode(["success" => false, "message" => "Neues Passwort zu kurz (min. 6 Zeichen)."]); echo json_encode(["success" => false, "message" => "New password too short (min 6 characters)."]);
exit; exit;
} }
$store = load_store($STORE_FILE); // Verify the token belongs to the user requesting the change
if (($tokenRec['userID'] ?? '') === '') {
// Fall A: User hat schon Hash -> altes Passwort gegen Hash prüfen http_response_code(403);
if (array_key_exists($username, $store) && isset($store[$username]['hash'])) { echo json_encode(["success" => false, "message" => "Token not associated with a user"]);
$hash = (string)$store[$username]['hash'];
if (!password_verify($oldPassword, $hash)) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Altes Passwort falsch."]);
exit; exit;
}
} else {
// Fall B: User hat noch keinen Hash -> gegen Default prüfen
if (!array_key_exists($username, $USERS_DEFAULT) || $USERS_DEFAULT[$username] !== $oldPassword) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Altes Passwort falsch."]);
exit;
}
} }
// neuen Hash setzen (bcrypt) try {
$store[$username] = [ [$pdo, $tmpDb, $lockFp] = qdb_open(true);
"hash" => password_hash($newPassword, PASSWORD_DEFAULT),
"changedAt" => time()
];
save_store($STORE_FILE, $store);
// Nach erfolgreichem Wechsel direkt einloggen (Token erstellen) $stmt = $pdo->prepare(
$token = bin2hex(random_bytes(32)); "SELECT userID, passwordHash, role, entityID FROM users WHERE username = :u"
token_add($token, 30 * 24 * 60 * 60); );
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode([ if (!$user) {
qdb_discard($tmpDb, $lockFp);
http_response_code(401);
echo json_encode(["success" => false, "message" => "Invalid credentials."]);
exit;
}
if ($user['userID'] !== $tokenRec['userID']) {
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode(["success" => false, "message" => "Token does not match user"]);
exit;
}
if (!password_verify($oldPassword, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp);
http_response_code(401);
echo json_encode(["success" => false, "message" => "Old password incorrect."]);
exit;
}
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$upd = $pdo->prepare(
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
);
$upd->execute([':h' => $newHash, ':uid' => $user['userID']]);
$pdo = null;
qdb_save($tmpDb, $lockFp);
$token = bin2hex(random_bytes(32));
token_add($token, 30 * 24 * 60 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
echo json_encode([
"success" => true, "success" => true,
"message" => "Passwort geändert.", "message" => "Password changed.",
"token" => $token, "token" => $token,
"user" => $username "user" => $username,
]); "role" => $user['role'],
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["success" => false, "message" => "Server error"]);
}

View File

@ -1,7 +1,7 @@
<?php <?php
header('Content-Type: application/json'); header('Content-Type: application/json');
$dbPath = '/var/www/html/uploads/questionnaire_database'; $dbPath = __DIR__ . '/uploads/questionnaire_database';
if (file_exists($dbPath)) { if (file_exists($dbPath)) {
echo json_encode(["exists" => true]); echo json_encode(["exists" => true]);

21
cli/cleanup_sessions.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* CLI: Remove expired sessions from the database.
* Usage: php cli/cleanup_sessions.php
*/
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo "CLI only\n";
exit(1);
}
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$stmt = $pdo->prepare("DELETE FROM session WHERE expiresAt < :now");
$stmt->execute([':now' => time()]);
$removed = $stmt->rowCount();
qdb_save($tmpDb, $lockFp);
echo "Removed $removed expired session(s).\n";

View File

@ -2,20 +2,62 @@
// /var/www/html/common.php // /var/www/html/common.php
// Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt). // Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt).
/**
* Load KEY=value pairs from .env in the project root.
* Does not override variables already set in the real environment.
*/
function qdb_load_dotenv(string $path): void {
if (!is_readable($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$eq = strpos($line, '=');
if ($eq === false) {
continue;
}
$name = trim(substr($line, 0, $eq));
$value = trim(substr($line, $eq + 1));
if ($name === '') {
continue;
}
$len = strlen($value);
if ($len >= 2) {
$q = $value[0];
if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) {
$value = substr($value, 1, -1);
}
}
$existing = getenv($name);
if ($existing !== false && $existing !== '') {
continue;
}
putenv("$name=$value");
$_ENV[$name] = $value;
}
}
qdb_load_dotenv(__DIR__ . '/.env');
// --- MASTER-KEY (Server-seitig zum Speichern der DB) --- // --- MASTER-KEY (Server-seitig zum Speichern der DB) ---
// Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String) oder Fallback auf bisherigen Wert. // Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String) oder Fallback auf bisherigen Wert.
function get_master_key_bytes(): string { function get_master_key_bytes(): string {
$env = getenv('QDB_MASTER_KEY'); $env = getenv('QDB_MASTER_KEY');
if ($env && $env !== '') { if (!$env || $env === '') {
// Versuche Base64 zu dekodieren, sonst als ASCII nehmen throw new RuntimeException('QDB_MASTER_KEY environment variable is not set. Cannot proceed without a master key.');
}
$b = base64_decode($env, true); $b = base64_decode($env, true);
if ($b !== false && strlen($b) > 0) { if ($b !== false && strlen($b) > 0) {
return str_pad(substr($b, 0, 32), 32, "\0"); return str_pad(substr($b, 0, 32), 32, "\0");
} }
return str_pad(substr($env, 0, 32), 32, "\0"); return str_pad(substr($env, 0, 32), 32, "\0");
}
// Fallback: alter Key aus bestehender Installation (32 ASCII-Bytes)
return "12345678901234567890123456789012";
} }
// --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) --- // --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) ---
@ -40,36 +82,124 @@ function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes',
return substr($okm, 0, $len); return substr($okm, 0, $len);
} }
// --- Token-Storage (mit Ablauf) --- // --- Token-Storage (DB-backed via session table) ---
function tokens_file_path(): string {
return __DIR__ . '/tokens.jsonl'; function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void {
} require_once __DIR__ . '/db_init.php';
function token_add(string $token, int $ttlSeconds = 86400): void { [$pdo, $tmpDb, $lockFp] = qdb_open(true);
$rec = [
'token' => $token,
'created' => time(),
'exp' => time() + $ttlSeconds,
];
file_put_contents(tokens_file_path(), json_encode($rec) . PHP_EOL, FILE_APPEND | LOCK_EX);
// optional weiterhin für Alt-Skripte:
file_put_contents(__DIR__ . '/valid_tokens.txt', $token . PHP_EOL, FILE_APPEND | LOCK_EX);
}
function token_is_valid(string $token): bool {
$f = tokens_file_path();
if (!file_exists($f)) return false;
$h = fopen($f, 'r');
if (!$h) return false;
$now = time(); $now = time();
$valid = false; $pdo->prepare(
while (($line = fgets($h)) !== false) { "INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
$j = json_decode($line, true); VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)"
if (!is_array($j)) continue; )->execute([
if (($j['token'] ?? '') === $token && ($j['exp'] ?? 0) >= $now) { ':t' => $token,
$valid = true; break; ':uid' => $extra['userID'] ?? '',
':role' => $extra['role'] ?? '',
':eid' => $extra['entityID'] ?? '',
':ca' => $now,
':ea' => $now + $ttlSeconds,
':tmp' => (int)(!empty($extra['temp'])),
]);
// Opportunistic cleanup: remove expired sessions
$pdo->prepare("DELETE FROM session WHERE expiresAt < :now")->execute([':now' => $now]);
qdb_save($tmpDb, $lockFp);
}
function token_get_record(string $token): ?array {
require_once __DIR__ . '/db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now");
$stmt->execute([':t' => $token, ':now' => time()]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
if (!$row) return null;
// Map DB columns to the keys the rest of the codebase expects
return [
'token' => $row['token'],
'role' => $row['role'],
'entityID' => $row['entityID'],
'userID' => $row['userID'],
'created' => (int)$row['createdAt'],
'exp' => (int)$row['expiresAt'],
'temp' => (int)$row['temp'],
];
}
function token_revoke(string $token): void {
require_once __DIR__ . '/db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => $token]);
qdb_save($tmpDb, $lockFp);
}
// --- Auth helpers for endpoints ---
function get_bearer_token(): ?string {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
if (stripos($auth, 'Bearer ') === 0) {
return substr($auth, 7);
} }
return null;
}
function require_valid_token(): array {
$token = get_bearer_token();
if (!$token) {
http_response_code(401);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Missing Bearer token"]);
exit;
}
$rec = token_get_record($token);
if (!$rec) {
http_response_code(403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Invalid or expired token"]);
exit;
}
if (!empty($rec['temp'])) {
http_response_code(403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Password change required before access"]);
exit;
}
$rec['_token'] = $token;
return $rec;
}
function require_role(array $allowed, array $tokenRecord): void {
$role = $tokenRecord['role'] ?? '';
if (!in_array($role, $allowed, true)) {
http_response_code(403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Insufficient permissions"]);
exit;
}
}
/**
* Build a SQL WHERE clause fragment that restricts client visibility by role.
* Returns [string $clause, array $params].
* $clientAlias is the table alias or name that has a coachID column (e.g. 'client').
*/
function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'): array {
$role = $tokenRecord['role'] ?? '';
$entityID = $tokenRecord['entityID'] ?? '';
switch ($role) {
case 'admin':
return ['1=1', []];
case 'supervisor':
return [
"$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)",
[':rbac_eid' => $entityID]
];
case 'coach':
return [
"$clientAlias.coachID = :rbac_eid",
[':rbac_eid' => $entityID]
];
default:
return ['0=1', []];
} }
fclose($h);
return $valid;
} }
// --- AES-256-CBC: IV(16) + CIPHERTEXT --- // --- AES-256-CBC: IV(16) + CIPHERTEXT ---

View File

@ -1,26 +1,45 @@
<?php <?php
// /var/www/html/db_download.php // /var/www/html/db_download.php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/db_init.php';
// Token prüfen $tokenRec = require_valid_token();
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
if (stripos($auth, 'Bearer ') !== 0) { http_response_code(401); echo "Fehlender Bearer-Token"; exit; }
$token = substr($auth, 7);
if (!token_is_valid($token)) { http_response_code(403); echo "Ungültiger oder abgelaufener Token"; exit; }
// DB entschlüsseln $dbPath = QDB_PATH;
$path = __DIR__ . '/uploads/questionnaire_database'; if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
if (!file_exists($path)) { http_response_code(404); echo "Datenbank nicht gefunden"; exit; } $enc = file_get_contents($dbPath);
$enc = file_get_contents($path); if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
if ($enc === false) { http_response_code(500); echo "Lesefehler"; exit; }
try { try {
$plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes()); $plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes());
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); echo "Entschlüsselung fehlgeschlagen"; exit; http_response_code(500); echo "Decryption failed"; exit;
}
$role = $tokenRec['role'] ?? '';
if ($role !== 'admin') {
$tmpFilter = tempnam(sys_get_temp_dir(), 'qdb_filt_');
file_put_contents($tmpFilter, $plain);
try {
$fpdo = new PDO("sqlite:$tmpFilter");
$fpdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$fpdo->exec("PRAGMA foreign_keys = OFF;");
[$clause, $params] = rbac_client_filter($tokenRec);
$delStmt = $fpdo->prepare("DELETE FROM client WHERE NOT ($clause)");
foreach ($params as $k => $v) $delStmt->bindValue($k, $v);
$delStmt->execute();
$fpdo->exec("DELETE FROM client_answer WHERE clientCode NOT IN (SELECT clientCode FROM client)");
$fpdo->exec("DELETE FROM completed_questionnaire WHERE clientCode NOT IN (SELECT clientCode FROM client)");
// Remove sensitive tables that non-admin users should never receive
foreach (['users', 'admin', 'supervisor', 'coach'] as $tbl) {
$fpdo->exec("DELETE FROM $tbl");
}
$fpdo = null;
$plain = file_get_contents($tmpFilter);
} finally {
@unlink($tmpFilter);
}
} }
// Ausliefern
header('Content-Type: application/octet-stream'); header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="questionnaire.sqlite"'); header('Content-Disposition: attachment; filename="questionnaire.sqlite"');
header('Content-Length: ' . strlen($plain)); header('Content-Length: ' . strlen($plain));

120
db_init.php Normal file
View File

@ -0,0 +1,120 @@
<?php
// /var/www/html/db_init.php
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
require_once __DIR__ . '/common.php';
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 3);
/**
* Decrypt the master DB file into a temp SQLite file, or create a fresh one
* from schema.sql if no DB exists yet.
*
* Returns [PDO $pdo, string $tmpPath, resource|null $lockFp].
* If $writable is true, an exclusive lock is held -- caller MUST call
* qdb_save() or qdb_discard() afterwards.
*/
function qdb_open(bool $writable = false): array {
$dbPath = QDB_PATH;
if (!is_dir(dirname($dbPath))) {
if (!mkdir(dirname($dbPath), 0755, true) && !is_dir(dirname($dbPath))) {
throw new Exception("Could not create uploads directory");
}
}
$lockFp = null;
if ($writable) {
$lockFp = fopen(QDB_LOCK, 'c');
if ($lockFp === false) throw new Exception("Could not open lock file");
if (!flock($lockFp, LOCK_EX)) {
fclose($lockFp);
throw new Exception("Could not acquire lock");
}
}
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_');
$masterKey = get_master_key_bytes();
$sql = file_get_contents(QDB_SCHEMA);
if ($sql === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not read schema.sql");
}
if (file_exists($dbPath) && is_file($dbPath)) {
$storedEnc = @file_get_contents($dbPath);
if ($storedEnc === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not read stored DB");
}
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
if (file_put_contents($tmpDb, $decrypted) === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not write temp DB");
}
} else {
$pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec($sql);
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
$pdo = null;
}
$pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("PRAGMA foreign_keys = ON;");
// Schema upgrade: apply CREATE TABLE IF NOT EXISTS for any missing tables
$currentVersion = (int)$pdo->query("PRAGMA user_version")->fetchColumn();
if ($currentVersion < QDB_VERSION) {
$pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->exec($sql);
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
$pdo->exec("PRAGMA foreign_keys = ON;");
}
return [$pdo, $tmpDb, $lockFp];
}
/**
* Encrypt the temp DB and atomically write it back to the master path.
* Releases the lock.
*/
function qdb_save(string $tmpDb, $lockFp): void {
$masterKey = get_master_key_bytes();
$plainDb = file_get_contents($tmpDb);
if ($plainDb === false) throw new Exception("Could not read temp DB for save");
$enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey);
$dbPath = QDB_PATH;
$tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_');
if (file_put_contents($tmpEncrypted, $enc) === false) {
throw new Exception("Could not write encrypted DB");
}
if (!@rename($tmpEncrypted, $dbPath)) {
if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) {
throw new Exception("Could not save encrypted DB (rename/copy failed)");
}
}
@chmod($dbPath, 0644);
@unlink($tmpDb);
if ($lockFp) {
flock($lockFp, LOCK_UN);
fclose($lockFp);
}
}
/**
* Discard changes -- clean up temp file and release lock without saving.
*/
function qdb_discard(string $tmpDb, $lockFp): void {
@unlink($tmpDb);
if ($lockFp) {
@flock($lockFp, LOCK_UN);
@fclose($lockFp);
}
}

View File

@ -1,62 +1,61 @@
<?php <?php
// /var/www/html/db_view.php // /var/www/html/db_view.php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
// ---- 1) Token prüfen (aus Authorization: Bearer <token>) ---- $tokenRec = require_valid_token();
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
if (stripos($auth, 'Bearer ') !== 0) {
http_response_code(401); echo "Fehlender Bearer-Token"; exit;
}
$token = substr($auth, 7);
if (!token_is_valid($token)) {
http_response_code(403); echo "Ungültiger oder abgelaufener Token"; exit;
}
// ---- 2) Master-verschlüsselte DB laden & entschlüsseln ---- $dbPath = QDB_PATH;
$dbPath = __DIR__ . '/uploads/questionnaire_database'; if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
if (!file_exists($dbPath)) { http_response_code(404); echo "Datenbank nicht gefunden"; exit; }
$enc = file_get_contents($dbPath); $enc = file_get_contents($dbPath);
if ($enc === false) { http_response_code(500); echo "Lesefehler"; exit; } if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
try { try {
$plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes()); $plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes());
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); echo "Entschlüsselung fehlgeschlagen"; exit; http_response_code(500); echo "Decryption failed"; exit;
} }
// ---- 3) In temporäre SQLite-Datei schreiben ----
$tmp = tempnam(sys_get_temp_dir(), 'qdb_'); $tmp = tempnam(sys_get_temp_dir(), 'qdb_');
file_put_contents($tmp, $plain); file_put_contents($tmp, $plain);
// ---- 4) Tabellen lesen & als HTML ausgeben ----
try { try {
$pdo = new PDO("sqlite:$tmp"); $pdo = new PDO("sqlite:$tmp");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Tabellenliste
$tables = []; $tables = [];
$rs = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"); $rs = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
foreach ($rs as $row) $tables[] = $row['name']; foreach ($rs as $row) $tables[] = $row['name'];
// Ausgabe // Tables that should not be shown (auth data)
$hidden = ['users'];
echo "<style>h2{margin:24px 0 8px} table{border-collapse:collapse;width:100%} th,td{border:1px solid #ddd;padding:6px 8px;text-align:left} th{background:#f7f7f7}</style>"; echo "<style>h2{margin:24px 0 8px} table{border-collapse:collapse;width:100%} th,td{border:1px solid #ddd;padding:6px 8px;text-align:left} th{background:#f7f7f7}</style>";
echo "<p><b>Tabellen:</b> " . htmlspecialchars(implode(', ', $tables)) . "</p>"; echo "<p><b>Tables:</b> " . htmlspecialchars(implode(', ', array_diff($tables, $hidden))) . "</p>";
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
foreach ($tables as $t) { foreach ($tables as $t) {
echo "<h2>" . htmlspecialchars($t) . "</h2>"; if (in_array($t, $hidden, true)) continue;
// Spaltenüberschriften via PRAGMA echo "<h2>" . htmlspecialchars($t) . "</h2>";
$cols = [];
$cr = $pdo->query("PRAGMA table_info(" . $pdo->quote($t) . ")");
// PRAGMA table_info akzeptiert kein quote(..) direkt, also besser plain escapen:
// Workaround:
$t_esc = preg_replace('/[^A-Za-z0-9_]/', '', $t); $t_esc = preg_replace('/[^A-Za-z0-9_]/', '', $t);
$cols = [];
$cr = $pdo->query("PRAGMA table_info($t_esc)"); $cr = $pdo->query("PRAGMA table_info($t_esc)");
foreach ($cr as $c) $cols[] = $c['name']; foreach ($cr as $c) $cols[] = $c['name'];
// Daten holen (ohne Limit; bei sehr großen Tabellen ggf. LIMIT einbauen) $clientScoped = in_array('clientCode', $cols, true);
if ($clientScoped && ($tokenRec['role'] ?? '') !== 'admin') {
$sql = "SELECT t.* FROM $t_esc t JOIN client ON client.clientCode = t.clientCode WHERE $rbacClause";
$stmt = $pdo->prepare($sql);
foreach ($rbacParams as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
} else {
$stmt = $pdo->query("SELECT * FROM $t_esc"); $stmt = $pdo->query("SELECT * FROM $t_esc");
}
echo "<table><thead><tr>"; echo "<table><thead><tr>";
foreach ($cols as $c) echo "<th>" . htmlspecialchars($c) . "</th>"; foreach ($cols as $c) echo "<th>" . htmlspecialchars($c) . "</th>";
echo "</tr></thead><tbody>"; echo "</tr></thead><tbody>";
@ -72,7 +71,8 @@ try {
echo "</tbody></table>"; echo "</tbody></table>";
} }
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); echo "SQLite-Fehler: " . htmlspecialchars($e->getMessage()); error_log($e->getMessage());
http_response_code(500); echo "Server error";
} finally { } finally {
@unlink($tmp); @unlink($tmp);
} }

View File

@ -1,18 +1,13 @@
<?php <?php
// /var/www/html/db_view_ordered.php // /var/www/html/db_view_ordered.php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
// ---- Auth ---- $tokenRec = require_valid_token();
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
if (stripos($auth, 'Bearer ') !== 0) { http_response_code(401); echo "Missing Bearer token"; exit; }
$token = substr($auth, 7);
if (!token_is_valid($token)) { http_response_code(403); echo "Invalid or expired token"; exit; }
// ---- Paths for order, header labels and value translations ----
$ORDER_JSON = __DIR__ . '/header_order.json'; $ORDER_JSON = __DIR__ . '/header_order.json';
$LABELS_JSON = __DIR__ . '/questions_en.json'; // Spaltenüberschriften (2. Kopfzeile) $LABELS_JSON = __DIR__ . '/questions_en.json';
$VALS_JSON = __DIR__ . '/translations_en.json'; // NEU: Werte-Übersetzungen $VALS_JSON = __DIR__ . '/translations_en.json';
$order = @json_decode(@file_get_contents($ORDER_JSON), true); $order = @json_decode(@file_get_contents($ORDER_JSON), true);
$labels = @json_decode(@file_get_contents($LABELS_JSON), true); $labels = @json_decode(@file_get_contents($LABELS_JSON), true);
@ -22,10 +17,7 @@ if (!is_array($labels)) $labels = [];
if (!is_array($vals)) $vals = []; if (!is_array($vals)) $vals = [];
function t_val(string $id, string $raw, array $vals): string { function t_val(string $id, string $raw, array $vals): string {
// feste Wörter direkt durchlassen
if ($raw === '' || $raw === 'None' || $raw === 'Done' || $raw === 'Not Done') return $raw; if ($raw === '' || $raw === 'None' || $raw === 'Done' || $raw === 'Not Done') return $raw;
// Kandidaten: exakt, normalisiert, id+value
$norm = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $raw)); $norm = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $raw));
$cands = [$raw]; $cands = [$raw];
if ($norm && $norm !== $raw) $cands[] = $norm; if ($norm && $norm !== $raw) $cands[] = $norm;
@ -33,19 +25,15 @@ function t_val(string $id, string $raw, array $vals): string {
$cands[] = "{$id}-{$raw}"; $cands[] = "{$id}-{$raw}";
$cands[] = "{$id}_{$norm}"; $cands[] = "{$id}_{$norm}";
$cands[] = "{$id}-{$norm}"; $cands[] = "{$id}-{$norm}";
foreach ($cands as $k) { foreach ($cands as $k) {
if (isset($vals[$k]) && is_string($vals[$k]) && $vals[$k] !== '') return $vals[$k]; if (isset($vals[$k]) && is_string($vals[$k]) && $vals[$k] !== '') return $vals[$k];
} }
// Fallback: Versuch mit einfachen globalen Keys (z. B. "yes","no","consent_signed")
if (isset($vals[$norm])) return (string)$vals[$norm]; if (isset($vals[$norm])) return (string)$vals[$norm];
if (isset($vals[$raw])) return (string)$vals[$raw]; if (isset($vals[$raw])) return (string)$vals[$raw];
return $raw;
return $raw; // nichts gefunden → Original anzeigen
} }
// ---- load & decrypt DB ---- $dbPath = QDB_PATH;
$dbPath = __DIR__ . '/uploads/questionnaire_database';
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; } if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
$enc = file_get_contents($dbPath); $enc = file_get_contents($dbPath);
if ($enc === false) { http_response_code(500); echo "Read error"; exit; } if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
@ -53,7 +41,6 @@ if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
try { $plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes()); } try { $plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes()); }
catch (Throwable $e) { http_response_code(500); echo "Decryption failed"; exit; } catch (Throwable $e) { http_response_code(500); echo "Decryption failed"; exit; }
// ---- temp SQLite file ----
$tmp = tempnam(sys_get_temp_dir(), 'qdb_'); $tmp = tempnam(sys_get_temp_dir(), 'qdb_');
file_put_contents($tmp, $plain); file_put_contents($tmp, $plain);
@ -61,27 +48,49 @@ try {
$pdo = new PDO("sqlite:$tmp"); $pdo = new PDO("sqlite:$tmp");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$clients = $pdo->query("SELECT clientCode FROM clients ORDER BY clientCode")->fetchAll(PDO::FETCH_COLUMN) ?: []; [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$questionnaireIds = $pdo->query("SELECT id FROM questionnaires")->fetchAll(PDO::FETCH_COLUMN) ?: [];
$clientStmt = $pdo->prepare(
"SELECT client.clientCode FROM client WHERE $rbacClause ORDER BY client.clientCode"
);
foreach ($rbacParams as $k => $v) $clientStmt->bindValue($k, $v);
$clientStmt->execute();
$clients = $clientStmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
$questionnaireIds = $pdo->query("SELECT questionnaireID FROM questionnaire")->fetchAll(PDO::FETCH_COLUMN) ?: [];
$qidSet = array_fill_keys($questionnaireIds, true); $qidSet = array_fill_keys($questionnaireIds, true);
$completed = []; $completed = [];
$stmt = $pdo->query("SELECT clientCode, questionnaireId, isDone FROM completed_questionnaires"); $cqStmt = $pdo->prepare(
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { "SELECT cq.clientCode, cq.questionnaireID, cq.status
$c = $row['clientCode'] ?? 'undefined'; FROM completed_questionnaire cq
$q = $row['questionnaireId'] ?? 'undefined'; JOIN client ON client.clientCode = cq.clientCode
$completed[$c][$q] = ((int)($row['isDone'] ?? 0)) ? true : false; WHERE $rbacClause"
);
foreach ($rbacParams as $k => $v) $cqStmt->bindValue($k, $v);
$cqStmt->execute();
while ($row = $cqStmt->fetch(PDO::FETCH_ASSOC)) {
$completed[$row['clientCode']][$row['questionnaireID']] = $row['status'];
} }
// client_answer uses questionID, which maps to old "questionId" format
$answers = []; $answers = [];
$stmt = $pdo->query("SELECT clientCode, questionId, answerValue FROM answers"); $ansStmt = $pdo->prepare(
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { "SELECT ca.clientCode, ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue
$c = $row['clientCode'] ?? 'undefined'; FROM client_answer ca
$q = $row['questionId'] ?? 'undefined'; JOIN client ON client.clientCode = ca.clientCode
$answers[$c][$q] = (string)($row['answerValue'] ?? ''); WHERE $rbacClause"
);
foreach ($rbacParams as $k => $v) $ansStmt->bindValue($k, $v);
$ansStmt->execute();
while ($row = $ansStmt->fetch(PDO::FETCH_ASSOC)) {
$val = $row['freeTextValue'] ?? $row['answerOptionID'] ?? '';
if ($val === '' && $row['numericValue'] !== null) {
$val = (string)$row['numericValue'];
}
$answers[$row['clientCode']][$row['questionID']] = $val;
} }
// ---- HTML ----
echo '<style> echo '<style>
table{border-collapse:collapse;width:100%;} table{border-collapse:collapse;width:100%;}
th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top;} th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top;}
@ -93,12 +102,10 @@ try {
echo '<table id="qdb-table">'; echo '<table id="qdb-table">';
echo '<thead>'; echo '<thead>';
// Row 1: checkbox + IDs (bleiben als technische Schlüssel im Kopf Werte darunter sind übersetzt)
echo '<tr><th class="chkcol"><input type="checkbox" id="chkAll" title="select all"></th>'; echo '<tr><th class="chkcol"><input type="checkbox" id="chkAll" title="select all"></th>';
foreach ($order as $id) echo '<th data-id="'.htmlspecialchars($id).'">'.htmlspecialchars($id).'</th>'; foreach ($order as $id) echo '<th data-id="'.htmlspecialchars($id).'">'.htmlspecialchars($id).'</th>';
echo '</tr>'; echo '</tr>';
// Row 2: blank + English labels
echo '<tr><th class="chkcol"></th>'; echo '<tr><th class="chkcol"></th>';
foreach ($order as $id) { foreach ($order as $id) {
$lbl = $labels[$id] ?? ''; $lbl = $labels[$id] ?? '';
@ -113,7 +120,8 @@ try {
if ($id === 'client_code') { if ($id === 'client_code') {
$val = $client; $val = $client;
} elseif (isset($qidSet[$id]) && strpos($id, '-') === false) { } elseif (isset($qidSet[$id]) && strpos($id, '-') === false) {
$val = ($completed[$client][$id] ?? false) ? 'Done' : 'Not Done'; $status = $completed[$client][$id] ?? '';
$val = ($status === 'done' || $status === 'Done') ? 'Done' : ($status !== '' ? $status : 'Not Done');
} else { } else {
$raw = $answers[$client][$id] ?? ''; $raw = $answers[$client][$id] ?? '';
$val = (strlen(trim($raw)) > 0) ? t_val($id, $raw, $vals) : 'None'; $val = (strlen(trim($raw)) > 0) ? t_val($id, $raw, $vals) : 'None';
@ -126,7 +134,8 @@ try {
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
echo "Error: ".htmlspecialchars($e->getMessage()); error_log($e->getMessage());
echo "Server error";
} finally { } finally {
@unlink($tmp); @unlink($tmp);
} }

38
dev-router.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/**
* Router for PHP's built-in server (php -S does not read .htaccess).
*
* Usage from project root:
* php -S 127.0.0.1:8080 dev-router.php
*
* Then open http://127.0.0.1:8080/website/ for the SPA and /api/... for the API.
*/
declare(strict_types=1);
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
// Existing files (PHP, static assets, etc.)
$path = __DIR__ . $uri;
if ($uri !== '/' && is_file($path)) {
return false;
}
// API → front controller
if (str_starts_with($uri, '/api')) {
$_SERVER['SCRIPT_NAME'] = '/api/index.php';
$_SERVER['SCRIPT_FILENAME'] = __DIR__ . '/api/index.php';
require __DIR__ . '/api/index.php';
return true;
}
// Optional: serve SPA for /
if ($uri === '/') {
$idx = __DIR__ . '/website/index.html';
if (is_file($idx)) {
header('Content-Type: text/html; charset=UTF-8');
readfile($idx);
return true;
}
}
return false;

View File

@ -1,37 +1,50 @@
<?php <?php
// /var/www/html/downloadFull.php // /var/www/html/downloadFull.php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/db_init.php';
// Bearer Token $tokenRec = require_valid_token();
$headers = function_exists('getallheaders') ? getallheaders() : []; $token = $tokenRec['_token'];
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (stripos($auth, 'Bearer ') !== 0) {
http_response_code(403); echo "Kein Token übergeben"; exit;
}
$token = substr($auth, 7);
if (!token_is_valid($token)) {
http_response_code(403); echo "Ungültiger Token"; exit;
}
// Master-verschlüsselte DB laden $dbPath = QDB_PATH;
$dbPath = __DIR__ . '/uploads/questionnaire_database'; if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
if (!file_exists($dbPath)) { http_response_code(404); echo "Datenbank nicht gefunden"; exit; }
$encMaster = file_get_contents($dbPath); $encMaster = file_get_contents($dbPath);
if ($encMaster === false) { http_response_code(500); echo "Lesefehler"; exit; } if ($encMaster === false) { http_response_code(500); echo "Read error"; exit; }
// Mit Master-Key entschlüsseln
$masterKey = get_master_key_bytes(); $masterKey = get_master_key_bytes();
try { try {
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey); $plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); echo "Entschlüsselung fehlgeschlagen"; exit; http_response_code(500); echo "Decryption failed"; exit;
}
$role = $tokenRec['role'] ?? '';
if ($role !== 'admin') {
$tmpFilter = tempnam(sys_get_temp_dir(), 'qdb_filt_');
file_put_contents($tmpFilter, $plain);
try {
$fpdo = new PDO("sqlite:$tmpFilter");
$fpdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$fpdo->exec("PRAGMA foreign_keys = OFF;");
[$clause, $params] = rbac_client_filter($tokenRec);
$delStmt = $fpdo->prepare("DELETE FROM client WHERE NOT ($clause)");
foreach ($params as $k => $v) $delStmt->bindValue($k, $v);
$delStmt->execute();
$fpdo->exec("DELETE FROM client_answer WHERE clientCode NOT IN (SELECT clientCode FROM client)");
$fpdo->exec("DELETE FROM completed_questionnaire WHERE clientCode NOT IN (SELECT clientCode FROM client)");
// Remove sensitive tables that non-admin users should never receive
foreach (['users', 'admin', 'supervisor', 'coach'] as $tbl) {
$fpdo->exec("DELETE FROM $tbl");
}
$fpdo = null;
$plain = file_get_contents($tmpFilter);
} finally {
@unlink($tmpFilter);
}
} }
// Für diese Session neu verschlüsseln (Key aus Token via HKDF)
$sessionKey = hkdf_session_key_from_token($token); $sessionKey = hkdf_session_key_from_token($token);
$out = aes256_cbc_encrypt_bytes($plain, $sessionKey); $out = aes256_cbc_encrypt_bytes($plain, $sessionKey);
// Ausliefern
header('Content-Type: application/octet-stream'); header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="questionnaire_database"'); header('Content-Disposition: attachment; filename="questionnaire_database"');
header('Content-Length: ' . strlen($out)); header('Content-Length: ' . strlen($out));

166
handlers/answer_options.php Normal file
View File

@ -0,0 +1,166 @@
<?php
$tokenRec = require_valid_token();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
$qID = $_GET['questionID'] ?? '';
if (!$qID) {
json_error('MISSING_PARAM', 'questionID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex');
$stmt->execute([':qid' => $qID]);
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($options as &$o) {
$o['points'] = (int)$o['points'];
$o['orderIndex'] = (int)$o['orderIndex'];
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
$tr->execute([':id' => $o['answerOptionID']]);
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($o);
qdb_discard($tmpDb, $lockFp);
json_success(['answerOptions' => $options]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID']) || !isset($body['defaultText'])) {
json_error('MISSING_FIELDS', 'questionID and defaultText required', 400);
}
$id = bin2hex(random_bytes(16));
$qID = $body['questionID'];
$text = trim($body['defaultText']);
$points = (int)($body['points'] ?? 0);
$order = (int)($body['orderIndex'] ?? 0);
$nextQ = trim($body['nextQuestionId'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$chk = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id');
$chk->execute([':id' => $qID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
if ($order === 0) {
$max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id');
$max->execute([':id' => $qID]);
$order = (int)$max->fetchColumn();
}
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
qdb_save($tmpDb, $lockFp);
json_success(['answerOption' => [
'answerOptionID' => $id,
'questionID' => $qID,
'defaultText' => $text,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
'translations' => [],
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['answerOptionID'])) {
json_error('MISSING_FIELDS', 'answerOptionID is required', 400);
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare('SELECT * FROM answer_option WHERE answerOptionID = :id');
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$text = trim($body['defaultText'] ?? $row['defaultText']);
$points = (int)($body['points'] ?? $row['points']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['answerOption' => [
'answerOptionID' => $id,
'questionID' => $row['questionID'],
'defaultText' => $text,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['answerOptionID'])) {
json_error('MISSING_FIELDS', 'answerOptionID is required', 400);
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->beginTransaction();
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->prepare('UPDATE client_answer SET answerOptionID = NULL WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->commit();
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID']) || !is_array($body['order'] ?? null)) {
json_error('MISSING_FIELDS', 'questionID and order[] required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$stmt = $pdo->prepare('UPDATE answer_option SET orderIndex = :o WHERE answerOptionID = :id AND questionID = :qid');
foreach ($body['order'] as $idx => $aoid) {
$stmt->execute([':o' => $idx, ':id' => $aoid, ':qid' => $body['questionID']]);
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

View File

@ -0,0 +1,147 @@
<?php
/**
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> all translations merged across all tables
*/
$tokenRec = require_valid_token();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
if ($translations) {
$result = [];
$rows = $pdo->query("
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
FROM question_translation qt
JOIN question q ON q.questionID = qt.questionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
$rows = $pdo->query("
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
FROM answer_option_translation aot
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
qdb_discard($tmpDb, $lockFp);
json_success(["translations" => $result]);
}
if ($qnID) {
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$stmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, configJson
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$stmt->execute([':id' => $qnID]);
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questions = [];
foreach ($dbQuestions as $dbQ) {
$localId = $dbQ['questionID'];
$parts = explode('__', $localId);
$shortId = end($parts);
$layout = $dbQ['type'];
$config = json_decode($dbQ['configJson'], true) ?: [];
$q = [
'id' => $shortId,
'layout' => $layout,
'question' => $dbQ['defaultText'],
];
if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
if (isset($config['hint'])) $q['hint'] = $config['hint'];
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if (isset($config['range'])) $q['range'] = $config['range'];
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options'];
}
if ($layout === 'value_spinner' && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions'];
}
$ao = $pdo->prepare("
SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$ao->execute([':qid' => $dbQ['questionID']]);
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
if ($opts) {
$optionsArr = [];
$pointsMap = [];
foreach ($opts as $opt) {
$o = ['key' => $opt['defaultText']];
if ($opt['nextQuestionId'] !== '') {
$o['nextQuestionId'] = $opt['nextQuestionId'];
}
$optionsArr[] = $o;
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
}
$q['options'] = $optionsArr;
$q['pointsMap'] = $pointsMap;
}
$questions[] = $q;
}
qdb_discard($tmpDb, $lockFp);
json_success(["meta" => ["id" => $qnID], "questions" => $questions]);
}
// Default: ordered questionnaire list
$rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$list = [];
foreach ($rows as $r) {
$list[] = [
'id' => $r['questionnaireID'],
'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'],
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
];
}
qdb_discard($tmpDb, $lockFp);
json_success($list);

105
handlers/assignments.php Normal file
View File

@ -0,0 +1,105 @@
<?php
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($callerRole === 'admin') {
$coaches = $pdo->query(
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY sv.username, c.username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$stmt = $pdo->prepare(
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
WHERE c.supervisorID = :sid ORDER BY c.username"
);
$stmt->execute([':sid' => $callerEntityID]);
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
[$clause, $params] = rbac_client_filter($tokenRec);
$clientStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.coachID, cl.clientCode"
);
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
$clientStmt->execute();
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches, 'clients' => $clients]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'POST':
$body = read_json_body();
$clientCodes = $body['clientCodes'] ?? [];
$coachID = trim($body['coachID'] ?? '');
if (empty($clientCodes) || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCodes and coachID are required', 400);
}
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
if ($callerRole === 'supervisor') {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chk->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
} else {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chk->execute([':cid' => $coachID]);
}
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Coach not found or not authorized', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$pdo->beginTransaction();
$updStmt = $pdo->prepare(
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
);
$count = 0;
foreach ($clientCodes as $cc) {
$cc = trim($cc);
if ($cc === '') continue;
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
$count += $updStmt->rowCount();
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success(['assigned' => $count]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

150
handlers/auth.php Normal file
View File

@ -0,0 +1,150 @@
<?php
switch ($route) {
case 'auth/login':
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$body = read_json_body();
$username = trim($body['username'] ?? '');
$password = (string)($body['password'] ?? '');
if ($username === '' || $password === '') {
json_error('MISSING_FIELDS', 'Username and password are required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID, mustChangePassword
FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
if (!$user || !password_verify($password, $user['passwordHash'])) {
json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401);
}
if ((int)$user['mustChangePassword'] === 1) {
$tempToken = bin2hex(random_bytes(32));
token_add($tempToken, 10 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
'temp' => true,
]);
json_success([
'mustChangePassword' => true,
'token' => $tempToken,
'user' => $username,
'role' => $user['role'],
]);
}
$token = bin2hex(random_bytes(32));
token_add($token, 30 * 24 * 60 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
json_success([
'token' => $token,
'user' => $username,
'role' => $user['role'],
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'auth/change-password':
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$bearerToken = get_bearer_token();
if (!$bearerToken) {
json_error('UNAUTHORIZED', 'Bearer token required', 401);
}
$tokenRec = token_get_record($bearerToken);
if (!$tokenRec) {
json_error('FORBIDDEN', 'Invalid or expired token', 403);
}
$body = read_json_body();
$username = trim($body['username'] ?? '');
$oldPassword = (string)($body['old_password'] ?? '');
$newPassword = (string)($body['new_password'] ?? '');
if ($username === '' || $oldPassword === '' || $newPassword === '') {
json_error('MISSING_FIELDS', 'username, old_password, and new_password are required', 400);
}
if (strlen($newPassword) < 6) {
json_error('PASSWORD_TOO_SHORT', 'New password must be at least 6 characters', 400);
}
if (($tokenRec['userID'] ?? '') === '') {
json_error('FORBIDDEN', 'Token not associated with a user', 403);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_CREDENTIALS', 'Invalid credentials', 401);
}
if ($user['userID'] !== $tokenRec['userID']) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Token does not match user', 403);
}
if (!password_verify($oldPassword, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_CREDENTIALS', 'Old password incorrect', 401);
}
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$pdo->prepare(
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
)->execute([':h' => $newHash, ':uid' => $user['userID']]);
qdb_save($tmpDb, $lockFp);
// Revoke the old (possibly temp) token and issue a fresh one
token_revoke($bearerToken);
$newToken = bin2hex(random_bytes(32));
token_add($newToken, 30 * 24 * 60 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
json_success([
'token' => $newToken,
'user' => $username,
'role' => $user['role'],
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('NOT_FOUND', 'Unknown auth route', 404);
}

29
handlers/backup.php Normal file
View File

@ -0,0 +1,29 @@
<?php
$tokenRec = require_valid_token();
require_role(['admin'], $tokenRec);
$dbPath = QDB_PATH;
$backupDir = __DIR__ . '/../uploads/backups';
if (!file_exists($dbPath)) {
json_error('NOT_FOUND', 'No database to back up', 404);
}
if (!is_dir($backupDir)) {
if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
json_error('SERVER_ERROR', 'Could not create backups directory', 500);
}
}
$timestamp = date('Y-m-d_H-i-s');
$filename = "questionnaire_database.$timestamp";
$backupFile = "$backupDir/$filename";
if (!copy($dbPath, $backupFile)) {
json_error('SERVER_ERROR', 'Backup copy failed', 500);
}
@chmod($backupFile, 0644);
json_success(['filename' => $filename]);

73
handlers/download.php Normal file
View File

@ -0,0 +1,73 @@
<?php
$tokenRec = require_valid_token();
$token = $tokenRec['_token'];
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) {
json_error('NOT_FOUND', 'Database not found', 404);
}
$encMaster = file_get_contents($dbPath);
if ($encMaster === false) {
json_error('SERVER_ERROR', 'Could not read database', 500);
}
try {
$masterKey = get_master_key_bytes();
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
} catch (Throwable $e) {
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Decryption failed', 500);
}
$role = $tokenRec['role'] ?? '';
if ($role !== 'admin') {
$tmpFilter = tempnam(sys_get_temp_dir(), 'qdb_filt_');
file_put_contents($tmpFilter, $plain);
try {
$fpdo = new PDO("sqlite:$tmpFilter");
$fpdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$fpdo->exec("PRAGMA foreign_keys = OFF;");
[$clause, $params] = rbac_client_filter($tokenRec);
$delStmt = $fpdo->prepare("DELETE FROM client WHERE NOT ($clause)");
foreach ($params as $k => $v) $delStmt->bindValue($k, $v);
$delStmt->execute();
$fpdo->exec("DELETE FROM client_answer WHERE clientCode NOT IN (SELECT clientCode FROM client)");
$fpdo->exec("DELETE FROM completed_questionnaire WHERE clientCode NOT IN (SELECT clientCode FROM client)");
foreach (['users', 'admin', 'supervisor', 'coach'] as $tbl) {
$fpdo->exec("DELETE FROM $tbl");
}
$fpdo = null;
$plain = file_get_contents($tmpFilter);
} finally {
@unlink($tmpFilter);
}
}
$type = strtolower(trim($_GET['type'] ?? 'encrypted'));
if ($type === 'plain') {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="questionnaire_database.sqlite"');
header('Content-Length: ' . strlen($plain));
echo $plain;
exit;
}
$sessionKey = hkdf_session_key_from_token($token);
$out = aes256_cbc_encrypt_bytes($plain, $sessionKey);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="questionnaire_database"');
header('Content-Length: ' . strlen($out));
echo $out;
exit;

119
handlers/export.php Normal file
View File

@ -0,0 +1,119 @@
<?php
$tokenRec = require_valid_token();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
$qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
$optionTextMap = [];
foreach ($questionIDs as $qid) {
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid");
$aoStmt->execute([':qid' => $qid]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
}
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT cl.clientCode, cl.coachID,
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
WHERE $rbacClause
ORDER BY cl.clientCode
";
$params = array_merge([':qnid' => $qnID], $rbacParams);
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
$answerStmt = $pdo->prepare("
SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
");
$rows = [];
foreach ($clients as $c) {
$row = [
'clientCode' => $c['clientCode'],
'coachID' => $c['coachID'],
'status' => $c['status'],
'sumPoints' => $c['sumPoints'],
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
];
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
$answerMap = [];
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
foreach ($questions as $q) {
$qid = $q['questionID'];
if (isset($answerMap[$qid])) {
$a = $answerMap[$qid];
if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) {
$row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']];
} elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') {
$row[$q['defaultText']] = $a['freeTextValue'];
} elseif ($a['numericValue'] !== null) {
$row[$q['defaultText']] = $a['numericValue'];
} else {
$row[$q['defaultText']] = '';
}
} else {
$row[$q['defaultText']] = '';
}
}
$rows[] = $row;
}
qdb_discard($tmpDb, $lockFp);
// CSV output (not JSON -- overrides the router's Content-Type)
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
if (!empty($rows)) {
fputcsv($out, array_keys($rows[0]));
foreach ($rows as $row) {
fputcsv($out, array_values($row));
}
} else {
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) $header[] = $q['defaultText'];
fputcsv($out, $header);
}
fclose($out);

13
handlers/logout.php Normal file
View File

@ -0,0 +1,13 @@
<?php
if ($method !== 'DELETE' && $method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$token = get_bearer_token();
if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
}
token_revoke($token);
json_success(['loggedOut' => true]);

153
handlers/questionnaires.php Normal file
View File

@ -0,0 +1,153 @@
<?php
$tokenRec = require_valid_token();
switch ($method) {
case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query("
SELECT q.questionnaireID, q.name, q.version, q.state,
q.orderIndex, q.showPoints, q.conditionJson,
COUNT(qu.questionID) AS questionCount
FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
GROUP BY q.questionnaireID
ORDER BY q.orderIndex, q.name
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['orderIndex'] = (int)$r['orderIndex'];
$r['showPoints'] = (int)$r['showPoints'];
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
}
unset($r);
qdb_discard($tmpDb, $lockFp);
json_success(['questionnaires' => $rows]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['name'])) {
json_error('NAME_REQUIRED', 'name is required', 400);
}
$id = bin2hex(random_bytes(16));
$name = trim($body['name']);
$version = trim($body['version'] ?? '');
$state = trim($body['state'] ?? 'draft');
$order = (int)($body['orderIndex'] ?? 0);
$showPts = (int)($body['showPoints'] ?? 0);
$condJson = $body['conditionJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($order === 0) {
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
}
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version,
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
'conditionJson' => $condJson, 'questionCount' => 0,
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID'])) {
json_error('QUESTIONNAIRE_ID_REQUIRED', 'questionnaireID is required', 400);
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$name = trim($body['name'] ?? $row['name']);
$version = trim($body['version'] ?? $row['version']);
$state = trim($body['state'] ?? $row['state']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$showPts = (int)($body['showPoints'] ?? $row['showPoints']);
$condJson = $body['conditionJson'] ?? $row['conditionJson'];
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
orderIndex = :o, showPoints = :sp, conditionJson = :cj
WHERE questionnaireID = :id")
->execute([':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'DELETE':
require_role(['admin'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID'])) {
json_error('QUESTIONNAIRE_ID_REQUIRED', 'questionnaireID is required', 400);
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->beginTransaction();
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
$qIds->execute([':id' => $id]);
$questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($questionIDs as $qid) {
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :qid');
$aoIds->execute([':qid' => $qid]);
$optionIDs = $aoIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($optionIDs as $aoid) {
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $aoid]);
}
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
}
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->commit();
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

178
handlers/questions.php Normal file
View File

@ -0,0 +1,178 @@
<?php
$tokenRec = require_valid_token();
switch ($method) {
case 'GET':
$qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) {
json_error('BAD_REQUEST', 'questionnaireID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare("
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
");
$stmt->execute([':qid' => $qnID]);
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex'];
$q['configJson'] = $q['configJson'] ?: '{}';
$ao = $pdo->prepare("
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
}
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($q);
qdb_discard($tmpDb, $lockFp);
json_success(['questions' => $questions]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID']) || !isset($body['defaultText'])) {
json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400);
}
$id = bin2hex(random_bytes(16));
$qnID = $body['questionnaireID'];
$text = trim($body['defaultText']);
$type = trim($body['type'] ?? '');
$order = (int)($body['orderIndex'] ?? 0);
$req = (int)($body['isRequired'] ?? 0);
$config = $body['configJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
$chk->execute([':id' => $qnID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
if ($order === 0) {
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM question WHERE questionnaireID = :id");
$max->execute([':id' => $qnID]);
$order = (int)$max->fetchColumn();
}
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
':o' => $order, ':r' => $req, ':cj' => $config]);
qdb_save($tmpDb, $lockFp);
json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config, 'answerOptions' => [], 'translations' => [],
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID'])) {
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$text = trim($body['defaultText'] ?? $row['defaultText']);
$type = trim($body['type'] ?? $row['type']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$req = (int)($body['isRequired'] ?? $row['isRequired']);
$config = $body['configJson'] ?? $row['configJson'];
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config,
]]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID'])) {
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->beginTransaction();
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id");
$aoIds->execute([':id' => $id]);
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
}
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
$pdo->commit();
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$stmt = $pdo->prepare("UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid");
foreach ($body['order'] as $idx => $qid) {
$stmt->execute([':o' => $idx, ':id' => $qid, ':qid' => $body['questionnaireID']]);
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

113
handlers/results.php Normal file
View File

@ -0,0 +1,113 @@
<?php
$tokenRec = require_valid_token();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$qnID = $_GET['questionnaireID'] ?? '';
$clientCode = $_GET['clientCode'] ?? '';
if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$qStmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, isRequired
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex'];
$ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
}
}
unset($q);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT cl.clientCode, cl.coachID,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
WHERE $rbacClause
";
$params = array_merge([':qnid' => $qnID], $rbacParams);
if ($clientCode) {
$sql .= " AND cl.clientCode = :cc";
$params[':cc'] = $clientCode;
}
$sql .= " ORDER BY cl.clientCode";
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($questionIDs) && !empty($clients)) {
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
$answerStmt = $pdo->prepare("
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
");
foreach ($clients as &$c) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
$answerMap = [];
foreach ($answers as $a) {
$answerMap[$a['questionID']] = [
'answerOptionID' => $a['answerOptionID'],
'freeTextValue' => $a['freeTextValue'],
'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null,
'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null,
];
}
$c['answers'] = $answerMap;
}
unset($c);
} else {
foreach ($clients as &$c) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
$c['answers'] = (object)[];
}
unset($c);
}
qdb_discard($tmpDb, $lockFp);
json_success([
"questionnaire" => $questionnaire,
"questions" => $questions,
"clients" => $clients,
]);

237
handlers/translations.php Normal file
View File

@ -0,0 +1,237 @@
<?php
$tokenRec = require_valid_token();
$validTypes = ['question', 'answer_option', 'string', 'language'];
switch ($method) {
case 'GET':
if (isset($_GET['languages'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(["languages" => $rows]);
}
$qnID = $_GET['questionnaireID'] ?? '';
if ($qnID) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
$qStmt = $pdo->prepare("
SELECT q.questionID, q.defaultText, q.configJson
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
");
$qStmt->execute([':id' => $qnID]);
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$entries = [];
$stringKeys = [];
foreach ($dbQuestions as $dbQ) {
$entries[] = ['key' => $dbQ['defaultText'], 'type' => 'question', 'entityId' => $dbQ['questionID']];
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
$aoStmt->execute([':qid' => $dbQ['questionID']]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$entries[] = ['key' => $ao['defaultText'], 'type' => 'answer_option', 'entityId' => $ao['answerOptionID']];
}
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
foreach (['textKey','textKey1','textKey2','hint','hint1','hint2'] as $field) {
if (!empty($config[$field])) $stringKeys[$config[$field]] = true;
}
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
foreach ($config['symptoms'] as $s) $stringKeys[$s] = true;
}
}
foreach (array_keys($stringKeys) as $sk) {
$entries[] = ['key' => $sk, 'type' => 'string', 'entityId' => $sk];
}
$translations = [];
$qIds = array_filter(array_map(fn($e) => $e['type'] === 'question' ? $e['entityId'] : null, $entries));
if ($qIds) {
$ph = implode(',', array_fill(0, count($qIds), '?'));
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
$stmt->execute(array_values($qIds));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
}
}
$aoIds = array_filter(array_map(fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null, $entries));
if ($aoIds) {
$ph = implode(',', array_fill(0, count($aoIds), '?'));
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
$stmt->execute(array_values($aoIds));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
}
}
$sKeys = array_filter(array_map(fn($e) => $e['type'] === 'string' ? $e['entityId'] : null, $entries));
if ($sKeys) {
$ph = implode(',', array_fill(0, count($sKeys), '?'));
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
$stmt->execute(array_values($sKeys));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
}
}
foreach ($entries as &$e) {
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
}
unset($e);
qdb_discard($tmpDb, $lockFp);
json_success(["languages" => $languages, "entries" => $entries]);
}
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
if (!in_array($type, $validTypes, true) || (!$id && $type !== 'string')) {
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($type === 'question') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
$stmt->execute([':id' => $id]);
} elseif ($type === 'answer_option') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
$stmt->execute([':id' => $id]);
} else {
if ($id) {
$stmt = $pdo->prepare("SELECT stringKey, languageCode, text FROM string_translation WHERE stringKey = :id");
$stmt->execute([':id' => $id]);
} else {
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
}
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(["translations" => $rows]);
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$type = $body['type'] ?? '';
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
$name = trim($body['name'] ?? '');
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
->execute([':lc' => $lang, ':n' => $name]);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
$text = $body['text'] ?? '';
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($type === 'question') {
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} elseif ($type === 'answer_option') {
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} else {
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$type = $body['type'] ?? '';
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($type === 'question') {
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang")
->execute([':id' => $id, ':lang' => $lang]);
} elseif ($type === 'answer_option') {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang")
->execute([':id' => $id, ':lang' => $lang]);
} else {
$pdo->prepare("DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang")
->execute([':id' => $id, ':lang' => $lang]);
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

235
handlers/users.php Normal file
View File

@ -0,0 +1,235 @@
<?php
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($callerRole === 'admin') {
$users = $pdo->query(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
COALESCE(a.location, s.location, '') AS location,
c.supervisorID,
sv.username AS supervisorUsername
FROM users u
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY u.role, u.username"
)->fetchAll(PDO::FETCH_ASSOC);
$supervisors = $pdo->query(
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$svNameStmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
$svNameStmt->execute([':svid' => $callerEntityID]);
$svName = $svNameStmt->fetchColumn() ?: '';
$stmt = $pdo->prepare(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
'' AS location, c.supervisorID, :svname AS supervisorUsername
FROM users u
JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE c.supervisorID = :svid
ORDER BY u.username"
);
$stmt->execute([':svid' => $callerEntityID, ':svname' => $svName]);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$supervisors = [];
}
qdb_discard($tmpDb, $lockFp);
json_success([
'users' => $users,
'supervisors' => $supervisors,
'callerRole' => $callerRole,
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'POST':
$body = read_json_body();
$username = trim($body['username'] ?? '');
$password = (string)($body['password'] ?? '');
$role = strtolower(trim($body['role'] ?? ''));
$location = trim($body['location'] ?? '');
$supervisorID = trim($body['supervisorID'] ?? '');
$mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1;
if ($username === '' || $password === '' || $role === '') {
json_error('MISSING_FIELDS', 'username, password, and role are required', 400);
}
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
json_error('INVALID_ROLE', 'role must be admin, supervisor, or coach', 400);
}
if (strlen($password) < 6) {
json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400);
}
if ($callerRole === 'supervisor' && $role !== 'coach') {
json_error('FORBIDDEN', 'Supervisors may only create coaches', 403);
}
if ($callerRole === 'supervisor') {
$supervisorID = $callerEntityID;
}
if ($role === 'coach' && $supervisorID === '') {
json_error('MISSING_FIELDS', 'supervisorID is required for coaches', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('DUPLICATE', 'Username already exists', 409);
}
if ($role === 'coach') {
$svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
$svCheck->execute([':sid' => $supervisorID]);
if (!$svCheck->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Supervisor not found', 400);
}
if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Cannot create coaches for another supervisor', 403);
}
}
$entityID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
switch ($role) {
case 'admin':
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'supervisor':
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'coach':
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
break;
}
$pdo->prepare(
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)"
)->execute([
':uid' => $userID, ':u' => $username, ':hash' => $hash,
':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now,
]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
$svUsername = null;
if ($role === 'coach') {
[$pdo2, $tmp2, $lk2] = qdb_open(false);
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
$svr->execute([':sid' => $supervisorID]);
$svUsername = $svr->fetchColumn() ?: null;
qdb_discard($tmp2, $lk2);
}
json_success([
'userID' => $userID,
'entityID' => $entityID,
'username' => $username,
'role' => $role,
'location' => $location,
'supervisorID' => $role === 'coach' ? $supervisorID : null,
'supervisorUsername' => $svUsername,
'mustChangePassword' => $mustChange,
'createdAt' => $now,
]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'DELETE':
$body = read_json_body();
$targetUserID = trim($body['userID'] ?? '');
if ($targetUserID === '') {
json_error('MISSING_FIELDS', 'userID is required', 400);
}
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
json_error('FORBIDDEN', 'Cannot delete your own account', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'User not found', 404);
}
if ($callerRole === 'supervisor') {
if ($target['role'] !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Supervisors can only delete coaches', 403);
}
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid");
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Coach is not under your supervision', 403);
}
}
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM users WHERE userID = :uid")->execute([':uid' => $targetUserID]);
switch ($target['role']) {
case 'admin':
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")->execute([':id' => $target['entityID']]);
break;
case 'supervisor':
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")->execute([':id' => $target['entityID']]);
break;
case 'coach':
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")->execute([':id' => $target['entityID']]);
break;
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

174
import_questionnaires.php Normal file
View File

@ -0,0 +1,174 @@
<?php
/**
* One-time import: reads the temp-asset JSON files and populates the DB.
* Run from CLI: php import_questionnaires.php
* Or via browser (requires admin token): import_questionnaires.php
*/
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/db_init.php';
$isCli = (php_sapi_name() === 'cli');
if (!$isCli) {
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin'], $tokenRec);
}
function out(string $msg, bool $isCli): void {
if ($isCli) { echo $msg . "\n"; }
}
$assetsDir = __DIR__ . '/website/temp-assets';
$orderFile = $assetsDir . '/questionnaire_order.json';
$orderData = json_decode(file_get_contents($orderFile), true);
if (!$orderData) {
$err = "Could not read questionnaire_order.json";
if ($isCli) { die($err . "\n"); }
http_response_code(500);
echo json_encode(["error" => $err]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->beginTransaction();
foreach ($orderData as $orderIdx => $entry) {
$filename = $entry['file'];
$showPoints = (int)($entry['showPoints'] ?? 0);
$condition = json_encode($entry['condition'] ?? new stdClass());
$jsonPath = $assetsDir . '/' . $filename;
$qData = json_decode(file_get_contents($jsonPath), true);
if (!$qData) {
throw new Exception("Could not parse $filename");
}
$questionnaireID = $qData['meta']['id'];
$name = str_replace('_', ' ', preg_replace('/^questionnaire_\d+_/', '', $questionnaireID));
$name = ucwords($name);
out("Importing: $questionnaireID ($name)", $isCli);
$pdo->prepare("INSERT OR REPLACE INTO questionnaire
(questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
->execute([
':id' => $questionnaireID,
':n' => $name,
':v' => '1.0',
':s' => 'active',
':o' => $orderIdx,
':sp' => $showPoints,
':cj' => $condition,
]);
$questions = $qData['questions'] ?? [];
foreach ($questions as $qIdx => $q) {
$qLocalId = $q['id'];
$layout = $q['layout'];
$qKey = $q['question'] ?? '';
$questionID = $questionnaireID . '__' . $qLocalId;
$config = [];
if (isset($q['textKey'])) $config['textKey'] = $q['textKey'];
if (isset($q['textKey1'])) $config['textKey1'] = $q['textKey1'];
if (isset($q['textKey2'])) $config['textKey2'] = $q['textKey2'];
if (isset($q['hint'])) $config['hint'] = $q['hint'];
if (isset($q['hint1'])) $config['hint1'] = $q['hint1'];
if (isset($q['hint2'])) $config['hint2'] = $q['hint2'];
if (isset($q['symptoms'])) $config['symptoms'] = $q['symptoms'];
if (isset($q['range'])) $config['range'] = $q['range'];
if (isset($q['constraints'])) $config['constraints'] = $q['constraints'];
if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection'];
if ($layout === 'string_spinner' && isset($q['options']) && is_array($q['options'])
&& isset($q['options'][0]) && is_string($q['options'][0])) {
$config['options'] = $q['options'];
}
if ($layout === 'value_spinner' && isset($q['options']) && is_array($q['options'])) {
$valueOptions = [];
foreach ($q['options'] as $vo) {
if (is_array($vo) && isset($vo['value'])) {
$valueOptions[] = $vo;
}
}
if ($valueOptions) {
$config['valueOptions'] = $valueOptions;
}
}
$configJson = !empty($config) ? json_encode($config) : '{}';
$pdo->prepare("INSERT OR REPLACE INTO question
(questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qnid, :dt, :ty, :oi, :ir, :cj)")
->execute([
':id' => $questionID,
':qnid' => $questionnaireID,
':dt' => $qKey,
':ty' => $layout,
':oi' => $qIdx,
':ir' => 0,
':cj' => $configJson,
]);
if (isset($q['options']) && is_array($q['options'])) {
$hasObjects = isset($q['options'][0]) && is_array($q['options'][0]);
if ($hasObjects) {
$pointsMap = $q['pointsMap'] ?? [];
foreach ($q['options'] as $optIdx => $opt) {
$optKey = $opt['key'] ?? '';
if (!$optKey) continue;
$nextQId = '';
if (isset($opt['nextQuestionId'])) {
$nextQId = $opt['nextQuestionId'];
}
$pts = (int)($pointsMap[$optKey] ?? 0);
$answerOptionID = $questionID . '__opt_' . $optIdx;
$pdo->prepare("INSERT OR REPLACE INTO answer_option
(answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :dt, :p, :oi, :nq)")
->execute([
':id' => $answerOptionID,
':qid' => $questionID,
':dt' => $optKey,
':p' => $pts,
':oi' => $optIdx,
':nq' => $nextQId,
]);
}
}
}
}
out(" -> " . count($questions) . " questions imported", $isCli);
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
if ($isCli) {
echo "\nImport complete.\n";
} else {
echo json_encode(["success" => true, "message" => "Import complete"]);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
if ($isCli) {
die("ERROR: " . $e->getMessage() . "\n");
}
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}

View File

@ -9,66 +9,129 @@
.card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin-bottom: 16px; width: 100%; box-sizing: border-box; } .card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin-bottom: 16px; width: 100%; box-sizing: border-box; }
.hidden { display: none; } .hidden { display: none; }
.actions { margin: 12px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap; } .actions { margin: 12px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
button, a.button { padding:8px 12px; border:1px solid #ccc; border-radius:8px; background:#fff; cursor:pointer; text-decoration:none; } button, a.button { padding:8px 12px; border:1px solid #ccc; border-radius:8px; background:#fff; cursor:pointer; text-decoration:none; font-size:14px; }
button:hover, a.button:hover { background:#f7f7f7; } button:hover, a.button:hover { background:#f7f7f7; }
.muted { color:#666; } .muted { color:#666; }
.role-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:600; margin-left:6px; }
/* Nur der Tabellenbereich scrollt vertikal */ .role-admin { background:#e3f2fd; color:#1565c0; }
.role-supervisor { background:#fff3e0; color:#e65100; }
.role-coach { background:#e8f5e9; color:#2e7d32; }
.table-wrap { overflow-x: auto; border-radius: 8px; } .table-wrap { overflow-x: auto; border-radius: 8px; }
table { border-collapse: collapse; width: 100%; margin: 8px 0 24px; } table { border-collapse: collapse; width: 100%; margin: 8px 0 24px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; } th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
#qdb-table thead tr:nth-child(1) th { position: sticky; top: 0; z-index: 3; background: #f7f7f7; }
/* BEIDE Kopfzeilen fixieren */ #qdb-table thead tr:nth-child(2) th { position: sticky; top: var(--head1h, 34px); z-index: 2; background: #f7f7f7; }
#qdb-table thead tr:nth-child(1) th {
position: sticky;
top: 0;
z-index: 3;
background: #f7f7f7;
}
#qdb-table thead tr:nth-child(2) th {
position: sticky;
top: var(--head1h, 34px); /* wird per JS exakt gesetzt */
z-index: 2;
background: #f7f7f7;
}
.row-highlight { animation: hi 1.2s ease-in-out 0s 1; } .row-highlight { animation: hi 1.2s ease-in-out 0s 1; }
@keyframes hi { 0% { background:#fff9c4; } 100% { background:transparent; } } @keyframes hi { 0% { background:#fff9c4; } 100% { background:transparent; } }
.find-wrap { margin-left:auto; display:flex; gap:6px; align-items:center; } .find-wrap { margin-left:auto; display:flex; gap:6px; align-items:center; }
.find-wrap input[type="text"] { padding:6px 8px; border:1px solid #ccc; border-radius:8px; min-width:160px; } .find-wrap input[type="text"] { padding:6px 8px; border:1px solid #ccc; border-radius:8px; min-width:160px; }
#qdb-table th:nth-child(3), #qdb-table td:nth-child(3) { max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
input[type="text"], input[type="password"], select { padding:8px; border:1px solid #ccc; border-radius:8px; font-size:14px; }
.form-group { margin-bottom: 12px; }
.form-group label { display:block; margin-bottom:4px; font-weight:500; }
.msg { padding:8px 12px; border-radius:6px; margin:8px 0; }
.msg-error { background:#ffebee; color:#c62828; }
.msg-success { background:#e8f5e9; color:#2e7d32; }
/* kompaktere „lange“ Spalte (2. Daten-Spalte nach Checkbox) */ /* Assignment panel */
#qdb-table th:nth-child(3), .assign-grid { display:grid; grid-template-columns:1fr auto 1fr; gap:12px; align-items:start; margin-top:12px; }
#qdb-table td:nth-child(3) { .assign-list { border:1px solid #ddd; border-radius:8px; padding:8px; max-height:300px; overflow-y:auto; }
max-width: 260px; .assign-list h4 { margin:0 0 8px; }
white-space: nowrap; .assign-item { padding:6px 8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; }
overflow: hidden; .assign-item:hover { background:#f5f5f5; }
text-overflow: ellipsis; .assign-item.selected { background:#e3f2fd; }
} .assign-controls { display:flex; flex-direction:column; gap:8px; justify-content:center; align-items:center; padding-top:40px; }
.unassigned { color:#e65100; font-style:italic; }
/* User management panel */
.panel-cols { display:grid; grid-template-columns:1fr 340px; gap:20px; align-items:start; margin-top:12px; }
.users-table-wrap { overflow-x:auto; }
.users-table-wrap table { margin:0; }
.create-user-form { border:1px solid #ddd; border-radius:8px; padding:16px; }
.create-user-form h4 { margin:0 0 14px; }
.form-row { display:flex; gap:8px; }
.form-row .form-group { flex:1; }
.btn-danger { background:#fff; border-color:#f44336; color:#c62828; }
.btn-danger:hover { background:#ffebee; }
.btn-primary { background:#1565c0; border-color:#1565c0; color:#fff; }
.btn-primary:hover { background:#1976d2; }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
</head> </head>
<body> <body>
<h1>Questionnaire Database</h1> <h1>Questionnaire Database</h1>
<!-- LOGIN CARD -->
<div id="loginCard" class="card"> <div id="loginCard" class="card">
<h2>Login</h2> <h2>Login</h2>
<form id="loginForm" autocomplete="on"> <form id="loginForm" autocomplete="on">
<label>Username<br><input id="username" required autocomplete="username" /></label><br><br> <div class="form-group">
<label>Password<br><input id="password" type="password" required autocomplete="current-password" /></label><br><br> <label for="username">Username</label>
<button>Sign in</button> <input id="username" type="text" required autocomplete="username" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" type="password" required autocomplete="current-password" />
</div>
<button type="submit">Sign in</button>
</form> </form>
<p id="loginMsg"></p> <p id="loginMsg"></p>
</div> </div>
<!-- FORCED PASSWORD CHANGE CARD -->
<div id="changePwCard" class="card hidden">
<h2>Change Password</h2>
<p>You must change your password before continuing.</p>
<form id="changePwForm">
<div class="form-group">
<label for="cpOld">Current password</label>
<input id="cpOld" type="password" required />
</div>
<div class="form-group">
<label for="cpNew">New password (min 6 characters)</label>
<input id="cpNew" type="password" required minlength="6" />
</div>
<div class="form-group">
<label for="cpConfirm">Confirm new password</label>
<input id="cpConfirm" type="password" required minlength="6" />
</div>
<button type="submit">Change Password</button>
</form>
<p id="changePwMsg"></p>
</div>
<!-- VOLUNTARY PASSWORD CHANGE MODAL -->
<div id="volChangePwCard" class="card hidden">
<h2>Change Password</h2>
<form id="volChangePwForm">
<div class="form-group">
<label for="vcpOld">Current password</label>
<input id="vcpOld" type="password" required />
</div>
<div class="form-group">
<label for="vcpNew">New password (min 6 characters)</label>
<input id="vcpNew" type="password" required minlength="6" />
</div>
<div class="form-group">
<label for="vcpConfirm">Confirm new password</label>
<input id="vcpConfirm" type="password" required minlength="6" />
</div>
<button type="submit">Change Password</button>
<button type="button" id="vcpCancel">Cancel</button>
</form>
<p id="volChangePwMsg"></p>
</div>
<!-- MAIN APP CARD -->
<div id="appCard" class="card hidden"> <div id="appCard" class="card hidden">
<div class="actions"> <div class="actions">
<span>Signed in as <b id="who"></b>.</span> <span>Signed in as <b id="who"></b><span id="roleBadge" class="role-badge"></span></span>
<button id="logoutBtn">Logout</button> <button id="logoutBtn">Logout</button>
<button id="changePwBtn">Change Password</button>
<button id="exportSelectedBtn">Download</button> <button id="exportSelectedBtn">Download</button>
<span id="selInfo" class="muted">(0 selected)</span> <span id="selInfo" class="muted">(0 selected)</span>
<button id="assignBtn" class="hidden">Manage Assignments</button>
<button id="manageUsersBtn" class="hidden">Manage Users</button>
<div class="find-wrap"> <div class="find-wrap">
<label for="findClient">Find client:</label> <label for="findClient">Find client:</label>
@ -79,33 +142,82 @@
</div> </div>
<div class="table-wrap" id="tableWrap"> <div class="table-wrap" id="tableWrap">
<div id="dbContainer"><em>Loading</em></div> <div id="dbContainer"><em>Loading...</em></div>
</div> </div>
</div> </div>
<!-- ASSIGNMENT PANEL -->
<div id="assignCard" class="card hidden">
<h2>Manage Client Assignments</h2>
<button id="assignBackBtn">Back to Data View</button>
<div id="assignContent"><em>Loading...</em></div>
</div>
<!-- USER MANAGEMENT PANEL -->
<div id="manageUsersCard" class="card hidden">
<h2>Manage Users</h2>
<button id="manageUsersBackBtn">Back to Data View</button>
<div id="manageUsersContent"><em>Loading...</em></div>
</div>
<script> <script>
const $ = sel => document.querySelector(sel); const $ = sel => document.querySelector(sel);
let _pendingUser = null;
function setLoggedIn(user, token) { function roleBadgeClass(role) {
if (role === 'admin') return 'role-admin';
if (role === 'supervisor') return 'role-supervisor';
return 'role-coach';
}
function setLoggedIn(user, token, role) {
localStorage.setItem('qdb_user', user); localStorage.setItem('qdb_user', user);
localStorage.setItem('qdb_token', token); localStorage.setItem('qdb_token', token);
localStorage.setItem('qdb_role', role || '');
$('#who').textContent = user; $('#who').textContent = user;
const badge = $('#roleBadge');
badge.textContent = (role || '').charAt(0).toUpperCase() + (role || '').slice(1);
badge.className = 'role-badge ' + roleBadgeClass(role);
$('#loginCard').classList.add('hidden'); $('#loginCard').classList.add('hidden');
$('#changePwCard').classList.add('hidden');
$('#volChangePwCard').classList.add('hidden');
$('#assignCard').classList.add('hidden');
$('#manageUsersCard').classList.add('hidden');
$('#appCard').classList.remove('hidden'); $('#appCard').classList.remove('hidden');
if (role === 'admin' || role === 'supervisor') {
$('#assignBtn').classList.remove('hidden');
} else {
$('#assignBtn').classList.add('hidden');
}
if (role === 'admin') {
$('#manageUsersBtn').classList.remove('hidden');
} else {
$('#manageUsersBtn').classList.add('hidden');
}
loadDbView(); loadDbView();
} }
function setLoggedOut() { function setLoggedOut() {
localStorage.removeItem('qdb_user'); localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_token'); localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_role');
_pendingUser = null;
$('#dbContainer').innerHTML = ''; $('#dbContainer').innerHTML = '';
$('#appCard').classList.add('hidden'); $('#appCard').classList.add('hidden');
$('#changePwCard').classList.add('hidden');
$('#volChangePwCard').classList.add('hidden');
$('#assignCard').classList.add('hidden');
$('#manageUsersCard').classList.add('hidden');
$('#loginCard').classList.remove('hidden'); $('#loginCard').classList.remove('hidden');
} }
/* ---------- LOGIN ---------- */
async function login(evt) { async function login(evt) {
evt.preventDefault(); evt.preventDefault();
$('#loginMsg').textContent = 'Signing in'; $('#loginMsg').textContent = 'Signing in...';
try { try {
const res = await fetch('login.php', { const res = await fetch('login.php', {
method: 'POST', method: 'POST',
@ -120,19 +232,114 @@ async function login(evt) {
$('#loginMsg').textContent = data.message || 'Login failed'; $('#loginMsg').textContent = data.message || 'Login failed';
return; return;
} }
if (data.must_change_password) {
_pendingUser = data.user;
$('#loginCard').classList.add('hidden');
$('#changePwCard').classList.remove('hidden');
$('#changePwMsg').textContent = '';
$('#cpOld').value = $('#password').value;
$('#cpNew').value = '';
$('#cpConfirm').value = '';
$('#cpNew').focus();
return;
}
$('#loginMsg').textContent = ''; $('#loginMsg').textContent = '';
setLoggedIn(data.user, data.token); setLoggedIn(data.user, data.token, data.role);
} catch (e) { } catch (e) {
$('#loginMsg').textContent = 'Error: ' + e.message; $('#loginMsg').textContent = 'Error: ' + e.message;
} }
} }
/* ---------- FORCED PASSWORD CHANGE ---------- */
async function handleForcedPwChange(evt) {
evt.preventDefault();
const msg = $('#changePwMsg');
const newPw = $('#cpNew').value;
const confirm = $('#cpConfirm').value;
if (newPw.length < 6) { msg.textContent = 'New password must be at least 6 characters.'; return; }
if (newPw !== confirm) { msg.textContent = 'Passwords do not match.'; return; }
msg.textContent = 'Changing password...';
try {
const res = await fetch('change_password.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: _pendingUser,
old_password: $('#cpOld').value,
new_password: newPw
})
});
const data = await res.json();
if (!res.ok || !data.success) {
msg.textContent = data.message || 'Password change failed';
return;
}
setLoggedIn(data.user, data.token, data.role);
} catch (e) {
msg.textContent = 'Error: ' + e.message;
}
}
/* ---------- VOLUNTARY PASSWORD CHANGE ---------- */
function showVoluntaryPwChange() {
$('#appCard').classList.add('hidden');
$('#volChangePwCard').classList.remove('hidden');
$('#volChangePwMsg').textContent = '';
$('#vcpOld').value = '';
$('#vcpNew').value = '';
$('#vcpConfirm').value = '';
$('#vcpOld').focus();
}
function hideVoluntaryPwChange() {
$('#volChangePwCard').classList.add('hidden');
$('#appCard').classList.remove('hidden');
}
async function handleVoluntaryPwChange(evt) {
evt.preventDefault();
const msg = $('#volChangePwMsg');
const newPw = $('#vcpNew').value;
const confirm = $('#vcpConfirm').value;
if (newPw.length < 6) { msg.textContent = 'New password must be at least 6 characters.'; return; }
if (newPw !== confirm) { msg.textContent = 'Passwords do not match.'; return; }
msg.textContent = 'Changing password...';
try {
const res = await fetch('change_password.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: localStorage.getItem('qdb_user'),
old_password: $('#vcpOld').value,
new_password: newPw
})
});
const data = await res.json();
if (!res.ok || !data.success) {
msg.textContent = data.message || 'Password change failed';
return;
}
localStorage.setItem('qdb_token', data.token);
msg.textContent = '';
hideVoluntaryPwChange();
alert('Password changed successfully.');
} catch (e) {
msg.textContent = 'Error: ' + e.message;
}
}
/* ---------- DB VIEW ---------- */
function sizeTableArea() { function sizeTableArea() {
const wrap = document.getElementById('tableWrap'); const wrap = document.getElementById('tableWrap');
if (!wrap) return; if (!wrap) return;
const rect = wrap.getBoundingClientRect(); const rect = wrap.getBoundingClientRect();
const top = rect.top; const avail = window.innerHeight - rect.top - 16;
const avail = window.innerHeight - top - 16;
wrap.style.maxHeight = (avail > 200 ? avail : 200) + 'px'; wrap.style.maxHeight = (avail > 200 ? avail : 200) + 'px';
wrap.style.overflowY = 'auto'; wrap.style.overflowY = 'auto';
} }
@ -140,7 +347,7 @@ function sizeTableArea() {
async function loadDbView() { async function loadDbView() {
const token = localStorage.getItem('qdb_token'); const token = localStorage.getItem('qdb_token');
if (!token) return setLoggedOut(); if (!token) return setLoggedOut();
$('#dbContainer').innerHTML = '<em>Loading</em>'; $('#dbContainer').innerHTML = '<em>Loading...</em>';
try { try {
const res = await fetch('db_view_ordered.php', { const res = await fetch('db_view_ordered.php', {
headers: { 'Authorization': 'Bearer ' + token } headers: { 'Authorization': 'Bearer ' + token }
@ -154,6 +361,7 @@ async function loadDbView() {
$('#dbContainer').innerHTML = html; $('#dbContainer').innerHTML = html;
const table = document.querySelector('#qdb-table'); const table = document.querySelector('#qdb-table');
if (!table) return;
const selInfo = $('#selInfo'); const selInfo = $('#selInfo');
function updateCount(){ function updateCount(){
@ -171,8 +379,8 @@ async function loadDbView() {
updateCount(); updateCount();
$('#exportSelectedBtn').onclick = () => exportSelectedToXLSX(); $('#exportSelectedBtn').onclick = () => exportSelectedToXLSX();
buildClientSearchList(); buildClientSearchList();
const findInput = $('#findClient'); const findInput = $('#findClient');
const findBtn = $('#findBtn'); const findBtn = $('#findBtn');
findBtn.onclick = () => gotoClientRow(findInput.value.trim()); findBtn.onclick = () => gotoClientRow(findInput.value.trim());
@ -180,21 +388,18 @@ async function loadDbView() {
if (e.key === 'Enter') { e.preventDefault(); findBtn.click(); } if (e.key === 'Enter') { e.preventDefault(); findBtn.click(); }
}); });
/* Höhe der 1. Kopfzeile messen und als CSS-Variable setzen, if (table.tHead && table.tHead.rows.length >= 1) {
damit die 2. Kopfzeile darunter sticky stehen bleibt */
if (table && table.tHead && table.tHead.rows.length >= 1) {
const h1 = table.tHead.rows[0].getBoundingClientRect().height || 34; const h1 = table.tHead.rows[0].getBoundingClientRect().height || 34;
table.style.setProperty('--head1h', `${Math.round(h1)}px`); table.style.setProperty('--head1h', `${Math.round(h1)}px`);
} }
sizeTableArea(); sizeTableArea();
} catch (e) { } catch (e) {
$('#dbContainer').innerHTML = '<b>Error:</b> ' + e.message; $('#dbContainer').innerHTML = '<b>Error:</b> ' + e.message;
} }
} }
window.addEventListener('resize', sizeTableArea); window.addEventListener('resize', sizeTableArea);
/* ---------- EXPORT ---------- */
function exportSelectedToXLSX() { function exportSelectedToXLSX() {
const table = document.querySelector('#qdb-table'); const table = document.querySelector('#qdb-table');
if (!table) return alert('Table not found.'); if (!table) return alert('Table not found.');
@ -204,29 +409,20 @@ function exportSelectedToXLSX() {
const ids = []; const ids = [];
for (let i=1; i<headRows[0].cells.length; i++) { for (let i=1; i<headRows[0].cells.length; i++) {
const th = headRows[0].cells[i]; const th = headRows[0].cells[i];
const id = th.getAttribute('data-id') || th.textContent.trim(); ids.push(th.getAttribute('data-id') || th.textContent.trim());
ids.push(id);
} }
const labels = []; const labels = [];
for (let i=1; i<headRows[1].cells.length; i++) { for (let i=1; i<headRows[1].cells.length; i++) {
labels.push(headRows[1].cells[i].textContent.trim()); labels.push(headRows[1].cells[i].textContent.trim());
} }
const rows = Array.from(table.tBodies[0].rows) const rows = Array.from(table.tBodies[0].rows).filter(tr => tr.querySelector('.rowchk')?.checked);
.filter(tr => tr.querySelector('.rowchk')?.checked); if (rows.length === 0) { alert('Please select at least one row.'); return; }
if (rows.length === 0) {
alert('Please select at least one row.');
return;
}
const aoa = [];
aoa.push([...ids]);
aoa.push([...labels]);
const aoa = [[...ids], [...labels]];
rows.forEach(tr => { rows.forEach(tr => {
const tds = tr.cells;
const arr = []; const arr = [];
for (let i=1; i<tds.length; i++) arr.push(tds[i].textContent.trim()); for (let i=1; i<tr.cells.length; i++) arr.push(tr.cells[i].textContent.trim());
aoa.push(arr); aoa.push(arr);
}); });
@ -237,11 +433,10 @@ function exportSelectedToXLSX() {
XLSX.writeFile(wb, 'SelectedClients.xlsx'); XLSX.writeFile(wb, 'SelectedClients.xlsx');
} }
/* ======= Suche (nur Client-Code) ======= */ /* ---------- CLIENT SEARCH ---------- */
function buildClientSearchList() { function buildClientSearchList() {
const table = document.querySelector('#qdb-table'); const table = document.querySelector('#qdb-table');
if (!table) return; if (!table) return;
let clientCol = -1; let clientCol = -1;
const thead = table.tHead; const thead = table.tHead;
const rows = thead ? thead.rows : []; const rows = thead ? thead.rows : [];
@ -257,28 +452,19 @@ function buildClientSearchList() {
const dl = document.querySelector('#clientList'); const dl = document.querySelector('#clientList');
dl.innerHTML = ''; dl.innerHTML = '';
const codes = new Set(); const codes = new Set();
table.tBodies[0].querySelectorAll('tr').forEach(tr => { table.tBodies[0].querySelectorAll('tr').forEach(tr => {
const td = tr.cells[clientCol]; const td = tr.cells[clientCol];
if (!td) return; if (td) { const val = td.textContent.trim(); if (val) codes.add(val); }
const val = td.textContent.trim();
if (val) codes.add(val);
});
Array.from(codes).sort((a,b)=> (''+a).localeCompare(''+b, undefined, {numeric:true}))
.forEach(code => {
const opt = document.createElement('option');
opt.value = code;
dl.appendChild(opt);
}); });
Array.from(codes).sort((a,b)=> a.localeCompare(b, undefined, {numeric:true}))
.forEach(code => { const opt = document.createElement('option'); opt.value = code; dl.appendChild(opt); });
} }
function gotoClientRow(query) { function gotoClientRow(query) {
if (!query) return; if (!query) return;
const table = document.querySelector('#qdb-table'); const table = document.querySelector('#qdb-table');
if (!table) return; if (!table) return;
let clientCol = -1; let clientCol = -1;
const thead = table.tHead; const thead = table.tHead;
const rows = thead ? thead.rows : []; const rows = thead ? thead.rows : [];
@ -293,22 +479,17 @@ function gotoClientRow(query) {
if (clientCol < 0) return; if (clientCol < 0) return;
const tryCols = [clientCol, clientCol + 1, Math.max(0, clientCol - 1)]; const tryCols = [clientCol, clientCol + 1, Math.max(0, clientCol - 1)];
const bodyRows = Array.from(table.tBodies[0].rows); const bodyRows = Array.from(table.tBodies[0].rows);
let target = null; let target = null;
outer: outer:
for (const tr of bodyRows) { for (const tr of bodyRows) {
for (const col of tryCols) { for (const col of tryCols) {
const txt = (tr.cells[col]?.textContent || '').trim(); const txt = (tr.cells[col]?.textContent || '').trim();
if (!txt) continue; if (txt && (txt === query || txt.toLowerCase().includes(query.toLowerCase()))) {
if (txt === query || txt.toLowerCase().includes(query.toLowerCase())) { target = tr; break outer;
target = tr;
break outer;
} }
} }
} }
if (target) { if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('row-highlight'); target.classList.add('row-highlight');
@ -317,14 +498,337 @@ function gotoClientRow(query) {
alert('No matching client found.'); alert('No matching client found.');
} }
} }
/* ======= Ende Suche ======= */
/* ---------- ASSIGNMENT PANEL ---------- */
async function loadAssignments() {
const token = localStorage.getItem('qdb_token');
if (!token) return;
const content = $('#assignContent');
content.innerHTML = '<em>Loading...</em>';
try {
const res = await fetch('assign_client.php', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
content.innerHTML = '<b>Error:</b> ' + (err.error || res.statusText);
return;
}
const data = await res.json();
renderAssignments(data);
} catch (e) {
content.innerHTML = '<b>Error:</b> ' + e.message;
}
}
function renderAssignments(data) {
const { coaches, clients } = data;
const content = $('#assignContent');
let html = '<div class="assign-grid">';
// Left: Clients
html += '<div class="assign-list"><h4>Clients</h4>';
if (clients.length === 0) {
html += '<p class="muted">No clients found.</p>';
} else {
clients.forEach(c => {
const coachLabel = c.coachUsername || c.coachID || '';
const cls = coachLabel ? '' : ' unassigned';
html += `<div class="assign-item" data-client="${c.clientCode}">
<span>${c.clientCode}</span>
<span class="muted${cls}">${coachLabel || 'Unassigned'}</span>
</div>`;
});
}
html += '</div>';
// Center: Controls
html += '<div class="assign-controls">';
html += '<label for="assignCoachSelect">Assign to coach:</label>';
html += '<select id="assignCoachSelect">';
html += '<option value="">-- Select coach --</option>';
coaches.forEach(c => {
html += `<option value="${c.coachID}">${c.username} (${c.coachID})</option>`;
});
html += '</select>';
html += '<button id="doAssignBtn">Assign Selected</button>';
html += '<p id="assignMsg" class="muted"></p>';
html += '</div>';
// Right: empty placeholder for future use
html += '<div></div>';
html += '</div>';
content.innerHTML = html;
// Selection handling
let selectedClients = new Set();
content.querySelectorAll('.assign-item').forEach(el => {
el.addEventListener('click', () => {
const cc = el.dataset.client;
if (selectedClients.has(cc)) {
selectedClients.delete(cc);
el.classList.remove('selected');
} else {
selectedClients.add(cc);
el.classList.add('selected');
}
});
});
$('#doAssignBtn').addEventListener('click', async () => {
const coachID = $('#assignCoachSelect').value;
const msg = $('#assignMsg');
if (!coachID) { msg.textContent = 'Please select a coach.'; return; }
if (selectedClients.size === 0) { msg.textContent = 'Please select at least one client.'; return; }
msg.textContent = 'Assigning...';
const token = localStorage.getItem('qdb_token');
try {
const res = await fetch('assign_client.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
clientCodes: Array.from(selectedClients),
coachID: coachID
})
});
const result = await res.json();
if (!res.ok || !result.success) {
msg.textContent = result.error || result.message || 'Assignment failed';
return;
}
msg.textContent = 'Assigned successfully!';
setTimeout(() => loadAssignments(), 800);
} catch (e) {
msg.textContent = 'Error: ' + e.message;
}
});
}
function showAssignPanel() {
$('#appCard').classList.add('hidden');
$('#assignCard').classList.remove('hidden');
loadAssignments();
}
function hideAssignPanel() {
$('#assignCard').classList.add('hidden');
$('#appCard').classList.remove('hidden');
loadDbView();
}
/* ---------- USER MANAGEMENT PANEL ---------- */
function showManageUsersPanel() {
$('#appCard').classList.add('hidden');
$('#manageUsersCard').classList.remove('hidden');
loadManageUsers();
}
function hideManageUsersPanel() {
$('#manageUsersCard').classList.add('hidden');
$('#appCard').classList.remove('hidden');
loadDbView();
}
async function loadManageUsers() {
const token = localStorage.getItem('qdb_token');
if (!token) return;
const content = $('#manageUsersContent');
content.innerHTML = '<em>Loading...</em>';
try {
const res = await fetch('manage_users.php', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
content.innerHTML = '<b>Error:</b> ' + (err.error || res.statusText);
return;
}
const data = await res.json();
renderManageUsers(data);
} catch (e) {
content.innerHTML = '<b>Error:</b> ' + e.message;
}
}
function renderManageUsers(data) {
const { users, supervisors } = data;
const content = $('#manageUsersContent');
const me = localStorage.getItem('qdb_user');
// ------ Users table ------
let tableHtml = '<div class="users-table-wrap">';
tableHtml += '<table><thead><tr><th>Username</th><th>Role</th><th>Location / Supervisor</th><th>Must change pw?</th><th></th></tr></thead><tbody>';
if (users.length === 0) {
tableHtml += '<tr><td colspan="5"><em class="muted">No users found.</em></td></tr>';
} else {
users.forEach(u => {
const detail = u.role === 'coach'
? (u.supervisorUsername ? `${u.supervisorUsername}` : u.supervisorID || '—')
: (u.location || '—');
const detailLabel = u.role === 'coach' ? 'Supervisor: ' : 'Location: ';
const mustChg = u.mustChangePassword == 1 ? '⚠ Yes' : 'No';
const isSelf = u.username === me;
const delBtn = isSelf
? '<em class="muted">you</em>'
: `<button class="btn-danger del-user-btn" data-uid="${u.userID}" data-uname="${u.username}">Delete</button>`;
tableHtml += `<tr>
<td><strong>${u.username}</strong></td>
<td><span class="role-badge ${roleBadgeClass(u.role)}">${u.role}</span></td>
<td class="muted">${detailLabel}${detail}</td>
<td>${mustChg}</td>
<td>${delBtn}</td>
</tr>`;
});
}
tableHtml += '</tbody></table></div>';
// ------ Create user form ------
let svOptions = '<option value="">-- Select supervisor --</option>';
supervisors.forEach(s => {
svOptions += `<option value="${s.supervisorID}">${s.username} (${s.location || 'no location'})</option>`;
});
const formHtml = `
<div class="create-user-form">
<h4>Create New User</h4>
<div class="form-row">
<div class="form-group">
<label>Username</label>
<input type="text" id="nu_username" placeholder="username" autocomplete="off" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="nu_password" placeholder="min 6 chars" autocomplete="new-password" />
</div>
</div>
<div class="form-group">
<label>Role</label>
<select id="nu_role">
<option value="">-- Select role --</option>
<option value="admin">Admin</option>
<option value="supervisor">Supervisor</option>
<option value="coach">Coach</option>
</select>
</div>
<div class="form-group" id="nu_location_group" style="display:none">
<label>Location <span class="muted">(optional)</span></label>
<input type="text" id="nu_location" placeholder="e.g. Berlin" />
</div>
<div class="form-group" id="nu_supervisor_group" style="display:none">
<label>Supervisor</label>
<select id="nu_supervisor">${svOptions}</select>
</div>
<div class="form-group">
<label style="display:flex;gap:8px;align-items:center;cursor:pointer;">
<input type="checkbox" id="nu_mustchange" checked />
Require password change on first login
</label>
</div>
<button class="btn-primary" id="nu_submitBtn">Create User</button>
<p id="nu_msg" class="muted" style="margin-top:8px;"></p>
</div>`;
content.innerHTML = `<div class="panel-cols">${tableHtml}${formHtml}</div>`;
// Role select show/hide conditional fields
const roleSelect = content.querySelector('#nu_role');
roleSelect.addEventListener('change', () => {
const r = roleSelect.value;
content.querySelector('#nu_location_group').style.display = (r === 'admin' || r === 'supervisor') ? '' : 'none';
content.querySelector('#nu_supervisor_group').style.display = r === 'coach' ? '' : 'none';
});
// Delete buttons
content.querySelectorAll('.del-user-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const uname = btn.dataset.uname;
if (!confirm(`Delete user "${uname}"? This cannot be undone.`)) return;
const token = localStorage.getItem('qdb_token');
try {
const res = await fetch('manage_users.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ userID: btn.dataset.uid })
});
const result = await res.json();
if (!res.ok || !result.success) {
alert(result.error || 'Delete failed');
return;
}
loadManageUsers();
} catch (e) {
alert('Error: ' + e.message);
}
});
});
// Create user submit
content.querySelector('#nu_submitBtn').addEventListener('click', async () => {
const msg = content.querySelector('#nu_msg');
const username = content.querySelector('#nu_username').value.trim();
const password = content.querySelector('#nu_password').value;
const role = content.querySelector('#nu_role').value;
const location = content.querySelector('#nu_location').value.trim();
const supervisorID = content.querySelector('#nu_supervisor').value;
const mustChange = content.querySelector('#nu_mustchange').checked;
if (!username) { msg.textContent = 'Username is required.'; return; }
if (password.length < 6) { msg.textContent = 'Password must be at least 6 characters.'; return; }
if (!role) { msg.textContent = 'Please select a role.'; return; }
if (role === 'coach' && !supervisorID) { msg.textContent = 'Please select a supervisor for the coach.'; return; }
msg.textContent = 'Creating...';
const token = localStorage.getItem('qdb_token');
try {
const res = await fetch('manage_users.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ username, password, role, location, supervisorID, mustChangePassword: mustChange })
});
const result = await res.json();
if (!res.ok || !result.success) {
msg.textContent = result.error || 'Create failed';
return;
}
msg.textContent = '';
content.querySelector('#nu_username').value = '';
content.querySelector('#nu_password').value = '';
content.querySelector('#nu_role').value = '';
content.querySelector('#nu_location').value = '';
content.querySelector('#nu_supervisor').value = '';
content.querySelector('#nu_location_group').style.display = 'none';
content.querySelector('#nu_supervisor_group').style.display = 'none';
loadManageUsers();
} catch (e) {
msg.textContent = 'Error: ' + e.message;
}
});
}
/* ---------- EVENT LISTENERS ---------- */
$('#loginForm').addEventListener('submit', login); $('#loginForm').addEventListener('submit', login);
$('#changePwForm').addEventListener('submit', handleForcedPwChange);
$('#volChangePwForm').addEventListener('submit', handleVoluntaryPwChange);
$('#vcpCancel').addEventListener('click', hideVoluntaryPwChange);
$('#logoutBtn').addEventListener('click', setLoggedOut); $('#logoutBtn').addEventListener('click', setLoggedOut);
$('#changePwBtn').addEventListener('click', showVoluntaryPwChange);
$('#assignBtn').addEventListener('click', showAssignPanel);
$('#assignBackBtn').addEventListener('click', hideAssignPanel);
$('#manageUsersBtn').addEventListener('click', showManageUsersPanel);
$('#manageUsersBackBtn').addEventListener('click', hideManageUsersPanel);
/* ---------- AUTO-LOGIN ---------- */
const t = localStorage.getItem('qdb_token'); const t = localStorage.getItem('qdb_token');
const u = localStorage.getItem('qdb_user'); const u = localStorage.getItem('qdb_user');
if (t && u) setLoggedIn(u, t); const r = localStorage.getItem('qdb_role');
if (t && u) setLoggedIn(u, t, r);
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,102 @@
<?php
class ClientRepo
{
/**
* List coaches visible to the caller.
* Admin: all coaches. Supervisor: only their own coaches.
*
* @return list<array{coachID: string, username: string, supervisorID: string, supervisorUsername: ?string}>
*/
public static function listCoaches(PDO $pdo, string $callerRole, string $callerEntityID): array
{
if ($callerRole === 'admin') {
return $pdo->query("
SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY sv.username, c.username
")->fetchAll(PDO::FETCH_ASSOC);
}
$stmt = $pdo->prepare("
SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
WHERE c.supervisorID = :sid
ORDER BY c.username
");
$stmt->execute([':sid' => $callerEntityID]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* List clients visible to the caller (RBAC-filtered).
*
* @return list<array{clientCode: string, coachID: string, coachUsername: ?string}>
*/
public static function listClients(PDO $pdo, array $tokenRecord): array
{
[$clause, $params] = rbac_client_filter($tokenRecord);
$stmt = $pdo->prepare("
SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.coachID, cl.clientCode
");
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v);
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Re-assign clients to a coach, respecting RBAC.
*
* @param list<string> $clientCodes
* @return int Number of rows actually updated.
*/
public static function assignToCoach(PDO $pdo, array $clientCodes, string $coachID, array $tokenRecord): int
{
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRecord);
$stmt = $pdo->prepare(
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
);
$count = 0;
foreach ($clientCodes as $cc) {
$cc = trim($cc);
if ($cc === '') {
continue;
}
$stmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
$count += $stmt->rowCount();
}
return $count;
}
/**
* Check that a coach exists and is visible to the caller.
*/
public static function coachExists(PDO $pdo, string $coachID, string $callerRole, string $callerEntityID): bool
{
if ($callerRole === 'supervisor') {
$stmt = $pdo->prepare(
"SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid"
);
$stmt->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
} else {
$stmt = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$stmt->execute([':cid' => $coachID]);
}
return (bool) $stmt->fetch();
}
}

View File

@ -0,0 +1,146 @@
<?php
class QuestionRepo
{
public static function listByQuestionnaire(PDO $pdo, string $questionnaireID): array
{
$stmt = $pdo->prepare("
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
");
$stmt->execute([':qid' => $questionnaireID]);
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($questions as &$q) {
$q['isRequired'] = (int) $q['isRequired'];
$q['orderIndex'] = (int) $q['orderIndex'];
$q['configJson'] = $q['configJson'] ?: '{}';
$ao = $pdo->prepare("
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int) $opt['points'];
$opt['orderIndex'] = (int) $opt['orderIndex'];
}
unset($opt);
$tr = $pdo->prepare(
'SELECT languageCode, text FROM question_translation WHERE questionID = :qid'
);
$tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($q);
return $questions;
}
public static function findById(PDO $pdo, string $questionID): ?array
{
$stmt = $pdo->prepare('SELECT * FROM question WHERE questionID = :id');
$stmt->execute([':id' => $questionID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $row;
}
public static function create(
PDO $pdo,
string $id,
string $questionnaireID,
string $defaultText,
string $type,
int $orderIndex,
int $isRequired,
string $configJson
): void {
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)'
)->execute([
':id' => $id,
':qid' => $questionnaireID,
':t' => $defaultText,
':ty' => $type,
':o' => $orderIndex,
':r' => $isRequired,
':cj' => $configJson,
]);
}
public static function update(
PDO $pdo,
string $id,
string $defaultText,
string $type,
int $orderIndex,
int $isRequired,
string $configJson
): void {
$pdo->prepare(
'UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj
WHERE questionID = :id'
)->execute([
':t' => $defaultText,
':ty' => $type,
':o' => $orderIndex,
':r' => $isRequired,
':cj' => $configJson,
':id' => $id,
]);
}
public static function delete(PDO $pdo, string $id): void
{
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->beginTransaction();
try {
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :id');
$aoIds->execute([':id' => $id]);
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')
->execute([':id' => $aoid]);
}
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question WHERE questionID = :id')->execute([':id' => $id]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
} finally {
$pdo->exec('PRAGMA foreign_keys = ON;');
}
}
public static function reorder(PDO $pdo, string $questionnaireID, array $orderedIds): void
{
$stmt = $pdo->prepare(
'UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid'
);
foreach ($orderedIds as $idx => $questionId) {
$stmt->execute([
':o' => (int) $idx,
':id' => $questionId,
':qid' => $questionnaireID,
]);
}
}
public static function nextOrderIndex(PDO $pdo, string $questionnaireID): int
{
$max = $pdo->prepare(
'SELECT COALESCE(MAX(orderIndex), 0) + 1 FROM question WHERE questionnaireID = :id'
);
$max->execute([':id' => $questionnaireID]);
return (int) $max->fetchColumn();
}
}

View File

@ -0,0 +1,178 @@
<?php
class QuestionnaireRepo
{
/**
* @return list<array{
* questionnaireID: string,
* name: string,
* version: string,
* state: string,
* orderIndex: int,
* showPoints: int,
* conditionJson: string,
* questionCount: int
* }>
*/
public static function listAll(PDO $pdo): array
{
$rows = $pdo->query("
SELECT q.questionnaireID, q.name, q.version, q.state,
q.orderIndex, q.showPoints, q.conditionJson,
COUNT(qu.questionID) AS questionCount
FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
GROUP BY q.questionnaireID
ORDER BY q.orderIndex, q.name
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['orderIndex'] = (int) $r['orderIndex'];
$r['showPoints'] = (int) $r['showPoints'];
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
$r['questionCount'] = (int) $r['questionCount'];
}
unset($r);
return $rows;
}
/**
* @return list<array{
* id: string,
* name: string,
* showPoints: bool,
* condition: array|stdClass
* }>
*/
public static function listActive(PDO $pdo): array
{
$rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$list = [];
foreach ($rows as $r) {
$list[] = [
'id' => $r['questionnaireID'],
'name' => $r['name'],
'showPoints' => (bool) (int) $r['showPoints'],
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
];
}
return $list;
}
/**
* @return array<string, mixed>|null
*/
public static function findById(PDO $pdo, string $id): ?array
{
$stmt = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public static function create(
PDO $pdo,
string $id,
string $name,
string $version,
string $state,
int $orderIndex,
int $showPoints,
string $conditionJson
): void {
$stmt = $pdo->prepare('
INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
VALUES (:id, :n, :v, :s, :o, :sp, :cj)
');
$stmt->execute([
':id' => $id,
':n' => $name,
':v' => $version,
':s' => $state,
':o' => $orderIndex,
':sp' => $showPoints,
':cj' => $conditionJson,
]);
}
public static function update(
PDO $pdo,
string $id,
string $name,
string $version,
string $state,
int $orderIndex,
int $showPoints,
string $conditionJson
): void {
$stmt = $pdo->prepare('
UPDATE questionnaire
SET name = :n, version = :v, state = :s,
orderIndex = :o, showPoints = :sp, conditionJson = :cj
WHERE questionnaireID = :id
');
$stmt->execute([
':n' => $name,
':v' => $version,
':s' => $state,
':o' => $orderIndex,
':sp' => $showPoints,
':cj' => $conditionJson,
':id' => $id,
]);
}
public static function delete(PDO $pdo, string $id): void
{
$pdo->exec('PRAGMA foreign_keys = OFF;');
try {
$pdo->beginTransaction();
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
$qIds->execute([':id' => $id]);
$questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($questionIDs as $qid) {
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :qid');
$aoIds->execute([':qid' => $qid]);
$optionIDs = $aoIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($optionIDs as $aoid) {
$delAot = $pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id');
$delAot->execute([':id' => $aoid]);
}
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
}
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
} finally {
$pdo->exec('PRAGMA foreign_keys = ON;');
}
}
public static function nextOrderIndex(PDO $pdo): int
{
return (int) $pdo->query('SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire')->fetchColumn();
}
}

View File

@ -0,0 +1,120 @@
<?php
class ResultRepo
{
/**
* Build the full results payload for a questionnaire, optionally filtered to one client.
* Uses rbac_client_filter() so callers only see what their role permits.
*
* @return array{questionnaire: array, questions: list<array>, clients: list<array>}
*/
public static function getQuestionnaireResults(
PDO $pdo,
string $questionnaireID,
array $tokenRecord,
?string $clientCode = null
): array {
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $questionnaireID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
return ['questionnaire' => null, 'questions' => [], 'clients' => []];
}
$qStmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, isRequired
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$qStmt->execute([':id' => $questionnaireID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
foreach ($questions as &$q) {
$q['isRequired'] = (int) $q['isRequired'];
$q['orderIndex'] = (int) $q['orderIndex'];
$ao = $pdo->prepare("
SELECT answerOptionID, defaultText, points, orderIndex
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int) $opt['points'];
$opt['orderIndex'] = (int) $opt['orderIndex'];
}
unset($opt);
}
unset($q);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRecord, 'cl');
$sql = "
SELECT cl.clientCode, cl.coachID,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl
INNER JOIN completed_questionnaire cq
ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
WHERE $rbacClause
";
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
if ($clientCode) {
$sql .= " AND cl.clientCode = :cc";
$params[':cc'] = $clientCode;
}
$sql .= " ORDER BY cl.clientCode";
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($questionIDs) && !empty($clients)) {
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
$answerStmt = $pdo->prepare("
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
");
foreach ($clients as &$c) {
self::castClientInts($c);
$answerStmt->execute(array_merge([$c['clientCode']], $questionIDs));
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
$answerMap = [];
foreach ($answers as $a) {
$answerMap[$a['questionID']] = [
'answerOptionID' => $a['answerOptionID'],
'freeTextValue' => $a['freeTextValue'],
'numericValue' => $a['numericValue'] !== null ? (float) $a['numericValue'] : null,
'answeredAt' => $a['answeredAt'] !== null ? (int) $a['answeredAt'] : null,
];
}
$c['answers'] = $answerMap;
}
unset($c);
} else {
foreach ($clients as &$c) {
self::castClientInts($c);
$c['answers'] = (object) [];
}
unset($c);
}
return [
'questionnaire' => $questionnaire,
'questions' => $questions,
'clients' => $clients,
];
}
private static function castClientInts(array &$c): void
{
$c['sumPoints'] = $c['sumPoints'] !== null ? (int) $c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int) $c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int) $c['completedAt'] : null;
}
}

View File

@ -0,0 +1,61 @@
<?php
class SessionRepo
{
public static function create(
PDO $pdo,
string $token,
string $userID,
string $role,
string $entityID,
int $ttl,
bool $temp = false
): void {
$now = time();
$pdo->prepare("
INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)
")->execute([
':t' => $token,
':uid' => $userID,
':role' => $role,
':eid' => $entityID,
':ca' => $now,
':ea' => $now + $ttl,
':tmp' => (int) $temp,
]);
}
/**
* @return array<string, mixed>|null Null when token is missing or expired.
*/
public static function findByToken(PDO $pdo, string $token): ?array
{
$stmt = $pdo->prepare(
"SELECT * FROM session WHERE token = :t AND expiresAt >= :now"
);
$stmt->execute([':t' => $token, ':now' => time()]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public static function revoke(PDO $pdo, string $token): void
{
$pdo->prepare("DELETE FROM session WHERE token = :t")
->execute([':t' => $token]);
}
/**
* Remove all expired sessions.
* @return int Number of rows deleted.
*/
public static function cleanup(PDO $pdo): int
{
$stmt = $pdo->prepare("DELETE FROM session WHERE expiresAt < :now");
$stmt->execute([':now' => time()]);
return $stmt->rowCount();
}
}

View File

@ -0,0 +1,103 @@
<?php
class TranslationRepo
{
private const TABLE_MAP = [
'question' => ['table' => 'question_translation', 'pk' => 'questionID'],
'answer_option' => ['table' => 'answer_option_translation', 'pk' => 'answerOptionID'],
'string' => ['table' => 'string_translation', 'pk' => 'stringKey'],
];
/**
* @return list<array{languageCode: string, name: string}>
*/
public static function listLanguages(PDO $pdo): array
{
return $pdo->query(
"SELECT languageCode, name FROM language ORDER BY languageCode"
)->fetchAll(PDO::FETCH_ASSOC);
}
public static function upsertLanguage(PDO $pdo, string $code, string $name): void
{
$pdo->prepare("
INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name
")->execute([':lc' => $code, ':n' => $name]);
}
/**
* Delete a language and all its translations across all three tables.
*/
public static function deleteLanguage(PDO $pdo, string $code): void
{
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")
->execute([':lc' => $code]);
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")
->execute([':lc' => $code]);
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")
->execute([':lc' => $code]);
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")
->execute([':lc' => $code]);
}
/**
* Get all translations for a given entity.
*
* @param string $type question|answer_option|string
* @return list<array{languageCode: string, text: string}>
*/
public static function getForEntity(PDO $pdo, string $type, string $id): array
{
$meta = self::meta($type);
$stmt = $pdo->prepare(
"SELECT languageCode, text FROM {$meta['table']} WHERE {$meta['pk']} = :id"
);
$stmt->execute([':id' => $id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Insert or update a single translation.
*
* @param string $type question|answer_option|string
*/
public static function upsert(PDO $pdo, string $type, string $id, string $lang, string $text): void
{
$meta = self::meta($type);
$pdo->prepare("
INSERT INTO {$meta['table']} ({$meta['pk']}, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT({$meta['pk']}, languageCode) DO UPDATE SET text = excluded.text
")->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
}
/**
* Delete a single translation.
*
* @param string $type question|answer_option|string
*/
public static function delete(PDO $pdo, string $type, string $id, string $lang): void
{
$meta = self::meta($type);
$pdo->prepare(
"DELETE FROM {$meta['table']} WHERE {$meta['pk']} = :id AND languageCode = :lang"
)->execute([':id' => $id, ':lang' => $lang]);
}
/**
* @return array{table: string, pk: string}
*/
private static function meta(string $type): array
{
if (!isset(self::TABLE_MAP[$type])) {
throw new InvalidArgumentException("Unknown translation type: $type");
}
return self::TABLE_MAP[$type];
}
}

View File

@ -0,0 +1,211 @@
<?php
class UserRepo
{
/**
* Full user list with role-table JOINs for location, supervisorID, supervisorUsername.
* @return list<array<string, mixed>>
*/
public static function listAll(PDO $pdo): array
{
return $pdo->query("
SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
COALESCE(a.location, s.location, '') AS location,
c.supervisorID,
sv.username AS supervisorUsername
FROM users u
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY u.role, u.username
")->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Coaches that belong to the given supervisor.
* @return list<array<string, mixed>>
*/
public static function listBySupervisor(PDO $pdo, string $supervisorID): array
{
$svName = self::getSupervisorName($pdo, $supervisorID);
$stmt = $pdo->prepare("
SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
'' AS location, c.supervisorID, :svname AS supervisorUsername
FROM users u
JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE c.supervisorID = :svid
ORDER BY u.username
");
$stmt->execute([':svid' => $supervisorID, ':svname' => $svName]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @return list<array{supervisorID: string, username: string, location: string}>
*/
public static function listSupervisors(PDO $pdo): array
{
return $pdo->query(
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
)->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @return array<string, mixed>|null
*/
public static function findByUsername(PDO $pdo, string $username): ?array
{
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :u");
$stmt->execute([':u' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* @return array{role: string, entityID: string}|null
*/
public static function findById(PDO $pdo, string $userID): ?array
{
$stmt = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
$stmt->execute([':uid' => $userID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public static function usernameExists(PDO $pdo, string $username): bool
{
$stmt = $pdo->prepare("SELECT 1 FROM users WHERE username = :u");
$stmt->execute([':u' => $username]);
return (bool) $stmt->fetch();
}
public static function supervisorExists(PDO $pdo, string $supervisorID): bool
{
$stmt = $pdo->prepare("SELECT 1 FROM supervisor WHERE supervisorID = :sid");
$stmt->execute([':sid' => $supervisorID]);
return (bool) $stmt->fetch();
}
public static function coachBelongsToSupervisor(PDO $pdo, string $coachEntityID, string $supervisorID): bool
{
$stmt = $pdo->prepare(
"SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid"
);
$stmt->execute([':cid' => $coachEntityID, ':svid' => $supervisorID]);
return (bool) $stmt->fetch();
}
/**
* Insert a new user inside a transaction: role-specific table first, then users.
* Caller is responsible for wrapping in qdb_open(true) / qdb_save().
*/
public static function create(
PDO $pdo,
string $userID,
string $entityID,
string $username,
string $passwordHash,
string $role,
string $location,
string $supervisorID,
int $mustChangePassword
): void {
$pdo->beginTransaction();
try {
switch ($role) {
case 'admin':
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'supervisor':
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'coach':
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
break;
}
$pdo->prepare("
INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)
")->execute([
':uid' => $userID,
':u' => $username,
':hash' => $passwordHash,
':role' => $role,
':eid' => $entityID,
':mcp' => $mustChangePassword,
':now' => time(),
]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/**
* Delete user + role-specific row in a transaction.
*/
public static function delete(PDO $pdo, string $userID, string $role, string $entityID): void
{
$pdo->beginTransaction();
try {
$pdo->prepare("DELETE FROM users WHERE userID = :uid")
->execute([':uid' => $userID]);
switch ($role) {
case 'admin':
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")
->execute([':id' => $entityID]);
break;
case 'supervisor':
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")
->execute([':id' => $entityID]);
break;
case 'coach':
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")
->execute([':id' => $entityID]);
break;
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
public static function updatePassword(PDO $pdo, string $userID, string $newHash): void
{
$pdo->prepare(
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
)->execute([':h' => $newHash, ':uid' => $userID]);
}
/**
* @return string Username or empty string if not found.
*/
public static function getSupervisorName(PDO $pdo, string $supervisorID): string
{
$stmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
$stmt->execute([':svid' => $supervisorID]);
return $stmt->fetchColumn() ?: '';
}
}

24
lib/response.php Normal file
View File

@ -0,0 +1,24 @@
<?php
function json_success(mixed $data, int $status = 200): never {
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["ok" => true, "data" => $data]);
exit;
}
function json_error(string $code, string $message, int $status = 400): never {
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["ok" => false, "error" => ["code" => $code, "message" => $message]]);
exit;
}
function read_json_body(): array {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (!is_array($data)) {
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
}
return $data;
}

71
lib/validate.php Normal file
View File

@ -0,0 +1,71 @@
<?php
/**
* Validate that all required fields are present and non-empty in the body.
* Calls json_error on failure (never returns in that case).
*/
function require_fields(array $body, array $fields): void {
$missing = [];
foreach ($fields as $f) {
if (!isset($body[$f]) || (is_string($body[$f]) && trim($body[$f]) === '')) {
$missing[] = $f;
}
}
if ($missing) {
json_error('MISSING_FIELDS', 'Required fields: ' . implode(', ', $missing), 400);
}
}
/**
* Validate a string value with optional min/max length.
* Returns the trimmed string or calls json_error.
*/
function validate_string(mixed $value, string $fieldName, int $min = 0, int $max = 0): string {
if (!is_string($value) && !is_numeric($value)) {
json_error('INVALID_FIELD', "$fieldName must be a string", 400);
}
$s = trim((string)$value);
if ($min > 0 && strlen($s) < $min) {
json_error('INVALID_FIELD', "$fieldName must be at least $min characters", 400);
}
if ($max > 0 && strlen($s) > $max) {
json_error('INVALID_FIELD', "$fieldName must be at most $max characters", 400);
}
return $s;
}
/**
* Validate that a value is one of the allowed options.
* Returns the value or calls json_error.
*/
function validate_enum(mixed $value, string $fieldName, array $allowed): string {
$s = trim((string)$value);
if (!in_array($s, $allowed, true)) {
json_error('INVALID_FIELD', "$fieldName must be one of: " . implode(', ', $allowed), 400);
}
return $s;
}
/**
* Validate and return an integer from body, with optional min/max bounds.
*/
function validate_int(mixed $value, string $fieldName, ?int $min = null, ?int $max = null): int {
$i = (int)$value;
if ($min !== null && $i < $min) {
json_error('INVALID_FIELD', "$fieldName must be at least $min", 400);
}
if ($max !== null && $i > $max) {
json_error('INVALID_FIELD', "$fieldName must be at most $max", 400);
}
return $i;
}
/**
* Require the request method to match, or exit with 405.
*/
function require_method(string ...$allowed): void {
$method = $_SERVER['REQUEST_METHOD'];
if (!in_array($method, $allowed, true)) {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
}

121
login.php
View File

@ -1,46 +1,8 @@
<?php <?php
// /var/www/html/login.php // /var/www/html/login.php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
// Default-Logins (Startzustand)
$USERS_DEFAULT = [
'user01' => 'pw1',
'user02' => 'pw2',
'Tmp_Daniel' => 'dummy4',
'Tmp_Johanna' => 'dummy1',
'Tmp_Anke' => 'dummy2',
'Tmp_Liliana' => 'dummy3',
'Amy_tmp' => 'dummy4',
'Brigitte_tmp' => 'dummy5',
'Extra1_tmp' => 'dummy6',
'Extra2_tmp' => 'dummy7',
'Danny' => 'pw1',
'User1' => 'pw1',
'User2' => 'pw2',
'User3' => 'pw3',
'User4' => 'pw4',
'User5' => 'pw5',
'User6' => 'pw6',
'User7' => 'pw7',
'User8' => 'pw8',
'User9' => 'pw9',
'User10' => 'pw10',
'User11' => 'pw11'
];
$STORE_FILE = __DIR__ . '/users.json'; // persistenter Speicher für gehashte Passwörter
function load_store($path) {
if (!file_exists($path)) return [];
$txt = file_get_contents($path);
$arr = json_decode($txt, true);
return is_array($arr) ? $arr : [];
}
function save_store($path, $data) {
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
$raw = file_get_contents("php://input"); $raw = file_get_contents("php://input");
$data = json_decode($raw, true) ?: []; $data = json_decode($raw, true) ?: [];
$username = trim($data['username'] ?? ''); $username = trim($data['username'] ?? '');
@ -48,44 +10,67 @@ $password = (string)($data['password'] ?? '');
if ($username === '' || $password === '') { if ($username === '' || $password === '') {
http_response_code(400); http_response_code(400);
echo json_encode(["success" => false, "message" => "Username/Passwort fehlt"]); echo json_encode(["success" => false, "message" => "Username/password missing"]);
exit; exit;
} }
// 1) Prüfe, ob Nutzer bereits ein *eigenes*, gehashtes Passwort hat try {
$store = load_store($STORE_FILE); // Struktur: ["user01" => ["hash" => "...", "changedAt" => 1234567890]] [$pdo, $tmpDb, $lockFp] = qdb_open(false);
if (array_key_exists($username, $store)) {
$hash = (string)($store[$username]['hash'] ?? ''); $stmt = $pdo->prepare(
if ($hash !== '' && password_verify($password, $hash)) { "SELECT userID, passwordHash, role, entityID, mustChangePassword
// ok -> normaler Login FROM users WHERE username = :u"
$token = bin2hex(random_bytes(32)); );
token_add($token, 30 * 24 * 60 * 60); $stmt->execute([':u' => $username]);
echo json_encode(["success" => true, "token" => $token, "user" => $username]); $user = $stmt->fetch(PDO::FETCH_ASSOC);
$pdo = null;
qdb_discard($tmpDb, $lockFp);
if (!$user) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Invalid credentials"]);
exit; exit;
} }
// hat schon Hash, aber PW falsch
http_response_code(401);
echo json_encode(["success" => false, "message" => "Ungültige Zugangsdaten"]);
exit;
}
// 2) Nutzer hat *noch* keinen Hash -> nur Default-Zugang erlaubt if (!password_verify($password, $user['passwordHash'])) {
if (!array_key_exists($username, $USERS_DEFAULT)) {
http_response_code(401); http_response_code(401);
echo json_encode(["success" => false, "message" => "Ungültige Zugangsdaten"]); echo json_encode(["success" => false, "message" => "Invalid credentials"]);
exit; exit;
} }
if ($USERS_DEFAULT[$username] !== $password) { if ((int)$user['mustChangePassword'] === 1) {
http_response_code(401); $tempToken = bin2hex(random_bytes(32));
echo json_encode(["success" => false, "message" => "Ungültige Zugangsdaten"]); token_add($tempToken, 10 * 60, [
exit; 'role' => $user['role'],
} 'entityID' => $user['entityID'],
'userID' => $user['userID'],
// 3) Default-Passwort korrekt -> Passwortwechsel erzwingen 'temp' => true,
echo json_encode([ ]);
echo json_encode([
"success" => true, "success" => true,
"user" => $username, "user" => $username,
"role" => $user['role'],
"must_change_password" => true, "must_change_password" => true,
"message" => "Bitte Passwort ändern." "temp_token" => $tempToken,
]); "message" => "Please change your password."
]);
exit;
}
$token = bin2hex(random_bytes(32));
token_add($token, 30 * 24 * 60 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
echo json_encode([
"success" => true,
"token" => $token,
"user" => $username,
"role" => $user['role'],
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(["success" => false, "message" => "Server error"]);
}

236
manage_users.php Normal file
View File

@ -0,0 +1,236 @@
<?php
// /var/www/html/manage_users.php
// Admin-only endpoint for user management.
//
// GET → list all users with role entity details + list of supervisors
// POST → create a new user { username, password, role, location?, supervisorID? }
// DELETE → delete a user { userID }
//
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin'], $tokenRec);
// -----------------------------------------------------------------------
// GET: list users + supervisors (for the coach creation dropdown)
// -----------------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$users = $pdo->query(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
COALESCE(a.location, s.location, '') AS location,
c.supervisorID,
sv.username AS supervisorUsername
FROM users u
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY u.role, u.username"
)->fetchAll(PDO::FETCH_ASSOC);
$supervisors = $pdo->query(
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
)->fetchAll(PDO::FETCH_ASSOC);
$pdo = null;
qdb_discard($tmpDb, $lockFp);
echo json_encode(["users" => $users, "supervisors" => $supervisors]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["error" => "Server error"]);
}
exit;
}
// -----------------------------------------------------------------------
// POST: create a user
// -----------------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = file_get_contents("php://input");
$in = json_decode($raw, true) ?: [];
$username = trim($in['username'] ?? '');
$password = (string)($in['password'] ?? '');
$role = strtolower(trim($in['role'] ?? ''));
$location = trim($in['location'] ?? '');
$supervisorID = trim($in['supervisorID'] ?? '');
$mustChange = isset($in['mustChangePassword']) ? (int)(bool)$in['mustChangePassword'] : 1;
if ($username === '' || $password === '' || $role === '') {
http_response_code(400);
echo json_encode(["error" => "username, password, and role are required"]);
exit;
}
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
http_response_code(400);
echo json_encode(["error" => "role must be admin, supervisor, or coach"]);
exit;
}
if (strlen($password) < 6) {
http_response_code(400);
echo json_encode(["error" => "Password must be at least 6 characters"]);
exit;
}
if ($role === 'coach' && $supervisorID === '') {
http_response_code(400);
echo json_encode(["error" => "supervisorID is required for coaches"]);
exit;
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(409);
echo json_encode(["error" => "Username already exists"]);
exit;
}
if ($role === 'coach') {
$sv = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
$sv->execute([':sid' => $supervisorID]);
if (!$sv->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(400);
echo json_encode(["error" => "supervisorID not found"]);
exit;
}
}
$entityID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
switch ($role) {
case 'admin':
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'supervisor':
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'coach':
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
break;
}
$pdo->prepare(
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)"
)->execute([
':uid' => $userID,
':u' => $username,
':hash' => $hash,
':role' => $role,
':eid' => $entityID,
':mcp' => $mustChange,
':now' => $now,
]);
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
echo json_encode([
"success" => true,
"userID" => $userID,
"entityID" => $entityID,
"username" => $username,
"role" => $role,
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["error" => "Server error"]);
}
exit;
}
// -----------------------------------------------------------------------
// DELETE: remove a user (and their role entity row)
// -----------------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$raw = file_get_contents("php://input");
$in = json_decode($raw, true) ?: [];
$targetUserID = trim($in['userID'] ?? '');
if ($targetUserID === '') {
http_response_code(400);
echo json_encode(["error" => "userID is required"]);
exit;
}
// Prevent self-deletion
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
http_response_code(400);
echo json_encode(["error" => "Cannot delete your own account"]);
exit;
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
http_response_code(404);
echo json_encode(["error" => "User not found"]);
exit;
}
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM users WHERE userID = :uid")
->execute([':uid' => $targetUserID]);
switch ($target['role']) {
case 'admin':
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")
->execute([':id' => $target['entityID']]);
break;
case 'supervisor':
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")
->execute([':id' => $target['entityID']]);
break;
case 'coach':
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")
->execute([':id' => $target['entityID']]);
break;
}
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}
exit;
}
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);

BIN
questionnaire_plain.sqlite Normal file

Binary file not shown.

132
schema.sql Normal file
View File

@ -0,0 +1,132 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
userID TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
passwordHash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','supervisor','coach')),
entityID TEXT NOT NULL,
mustChangePassword INTEGER NOT NULL DEFAULT 1,
createdAt INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS admin (
adminID TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL,
location TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS supervisor (
supervisorID TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL,
location TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS coach (
coachID TEXT NOT NULL PRIMARY KEY,
supervisorID TEXT NOT NULL,
username TEXT NOT NULL,
FOREIGN KEY(supervisorID) REFERENCES supervisor(supervisorID)
);
CREATE TABLE IF NOT EXISTS client (
clientCode TEXT NOT NULL PRIMARY KEY,
coachID TEXT NOT NULL,
FOREIGN KEY(coachID) REFERENCES coach(coachID)
);
CREATE TABLE IF NOT EXISTS questionnaire (
questionnaireID TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
orderIndex INTEGER NOT NULL DEFAULT 0,
showPoints INTEGER NOT NULL DEFAULT 0,
conditionJson TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS question (
questionID TEXT NOT NULL PRIMARY KEY,
questionnaireID TEXT NOT NULL,
defaultText TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
orderIndex INTEGER NOT NULL DEFAULT 0,
isRequired INTEGER NOT NULL DEFAULT 0,
configJson TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
);
CREATE TABLE IF NOT EXISTS answer_option (
answerOptionID TEXT NOT NULL PRIMARY KEY,
questionID TEXT NOT NULL,
defaultText TEXT NOT NULL DEFAULT '',
points INTEGER NOT NULL DEFAULT 0,
orderIndex INTEGER NOT NULL DEFAULT 0,
nextQuestionId TEXT NOT NULL DEFAULT '',
FOREIGN KEY(questionID) REFERENCES question(questionID)
);
CREATE TABLE IF NOT EXISTS client_answer (
clientCode TEXT NOT NULL,
questionID TEXT NOT NULL,
answerOptionID TEXT,
freeTextValue TEXT,
numericValue REAL,
answeredAt INTEGER,
PRIMARY KEY (clientCode, questionID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionID) REFERENCES question(questionID),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
);
CREATE TABLE IF NOT EXISTS completed_questionnaire (
clientCode TEXT NOT NULL,
questionnaireID TEXT NOT NULL,
assignedByCoach TEXT,
status TEXT NOT NULL DEFAULT '',
startedAt INTEGER,
completedAt INTEGER,
sumPoints INTEGER,
PRIMARY KEY (clientCode, questionnaireID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID)
);
CREATE TABLE IF NOT EXISTS question_translation (
questionID TEXT NOT NULL,
languageCode TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (questionID, languageCode),
FOREIGN KEY(questionID) REFERENCES question(questionID)
);
CREATE TABLE IF NOT EXISTS answer_option_translation (
answerOptionID TEXT NOT NULL,
languageCode TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (answerOptionID, languageCode),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
);
CREATE TABLE IF NOT EXISTS string_translation (
stringKey TEXT NOT NULL,
languageCode TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (stringKey, languageCode)
);
CREATE TABLE IF NOT EXISTS language (
languageCode TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS session (
token TEXT NOT NULL PRIMARY KEY,
userID TEXT NOT NULL,
role TEXT NOT NULL,
entityID TEXT NOT NULL DEFAULT '',
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL,
temp INTEGER NOT NULL DEFAULT 0
);

66
seed_admin.php Normal file
View File

@ -0,0 +1,66 @@
<?php
// CLI tool: php seed_admin.php <username> <password> [location]
// Creates an initial admin user in the encrypted database.
// If the DB doesn't exist yet, it is created from schema.sql.
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo "CLI only\n";
exit(1);
}
require_once __DIR__ . '/db_init.php';
$usage = "Usage: php seed_admin.php <username> <password> [location]\n";
$username = $argv[1] ?? '';
$password = $argv[2] ?? '';
$location = $argv[3] ?? '';
if ($username === '' || $password === '') {
fwrite(STDERR, $usage);
exit(1);
}
if (strlen($password) < 6) {
fwrite(STDERR, "Error: Password must be at least 6 characters.\n");
exit(1);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$existing = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$existing->execute([':u' => $username]);
if ($existing->fetch()) {
qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: User '$username' already exists.\n");
exit(1);
}
$adminID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $adminID, ':u' => $username, ':loc' => $location]);
$pdo->prepare(
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, 'admin', :eid, 1, :now)"
)->execute([':uid' => $userID, ':u' => $username, ':hash' => $hash, ':eid' => $adminID, ':now' => $now]);
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
echo "Admin user '$username' created successfully (must change password on first login).\n";
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
exit(1);
}

176
seed_user.php Normal file
View File

@ -0,0 +1,176 @@
<?php
// CLI tool for creating users of any role.
//
// Usage:
// php seed_user.php admin <username> <password> [location]
// php seed_user.php supervisor <username> <password> [location]
// php seed_user.php coach <username> <password> <supervisorID>
//
// Flags:
// --force-change Sets mustChangePassword = 1 (default ON, use --no-force-change to skip)
// --no-force-change Sets mustChangePassword = 0
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo "CLI only\n";
exit(1);
}
require_once __DIR__ . '/db_init.php';
$usage = <<<USAGE
Usage:
php seed_user.php admin <username> <password> [location]
php seed_user.php supervisor <username> <password> [location]
php seed_user.php coach <username> <password> <supervisorID>
Options:
--no-force-change Skip requiring password change on first login (default: require change)
--list List all existing users instead of creating one
USAGE;
// Strip flag args
$args = [];
$mustChangePassword = 1;
$listMode = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--no-force-change') { $mustChangePassword = 0; continue; }
if ($arg === '--force-change') { $mustChangePassword = 1; continue; }
if ($arg === '--list') { $listMode = true; continue; }
$args[] = $arg;
}
// List mode
if ($listMode) {
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query(
"SELECT u.username, u.role, u.entityID, u.mustChangePassword,
COALESCE(a.location, s.location, c.supervisorID) AS detail
FROM users u
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
ORDER BY u.role, u.username"
)->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
if (empty($rows)) {
echo "No users found.\n";
exit(0);
}
printf("%-20s %-12s %-40s %s\n", 'Username', 'Role', 'EntityID', 'Detail / SupervisorID');
echo str_repeat('-', 90) . "\n";
foreach ($rows as $r) {
$mustChange = (int)$r['mustChangePassword'] ? ' [must change pw]' : '';
printf("%-20s %-12s %-40s %s%s\n",
$r['username'], $r['role'], $r['entityID'],
$r['detail'] ?? '', $mustChange
);
}
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
exit(1);
}
}
if (count($args) < 3) {
fwrite(STDERR, $usage);
exit(1);
}
$role = strtolower($args[0]);
$username = $args[1];
$password = $args[2];
$extra = $args[3] ?? ''; // location (admin/supervisor) or supervisorID (coach)
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
fwrite(STDERR, "Error: role must be admin, supervisor, or coach.\n");
exit(1);
}
if ($username === '' || $password === '') {
fwrite(STDERR, $usage);
exit(1);
}
if (strlen($password) < 6) {
fwrite(STDERR, "Error: Password must be at least 6 characters.\n");
exit(1);
}
if ($role === 'coach' && $extra === '') {
fwrite(STDERR, "Error: coach requires a supervisorID as the 4th argument.\n");
exit(1);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
// Duplicate username check
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: User '$username' already exists.\n");
exit(1);
}
// For coach, verify supervisorID exists
if ($role === 'coach') {
$sv = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
$sv->execute([':sid' => $extra]);
if (!$sv->fetch()) {
qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: supervisorID '$extra' not found. Create the supervisor first.\n");
exit(1);
}
}
$entityID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
switch ($role) {
case 'admin':
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $extra]);
break;
case 'supervisor':
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $extra]);
break;
case 'coach':
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
->execute([':id' => $entityID, ':sid' => $extra, ':u' => $username]);
break;
}
$pdo->prepare(
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)"
)->execute([
':uid' => $userID,
':u' => $username,
':hash' => $hash,
':role' => $role,
':eid' => $entityID,
':mcp' => $mustChangePassword,
':now' => $now,
]);
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
$changeNote = $mustChangePassword ? ' (must change password on first login)' : '';
echo ucfirst($role) . " '$username' created successfully$changeNote.\n";
echo "EntityID: $entityID\n";
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
exit(1);
}

View File

@ -1,192 +1,24 @@
{"token":"731c101342f056ba57da7f958ba683fb41b0381d42de69090bcabee8c1b3a389","created":1756809382,"exp":1756895782} {"token":"e27b08a83d8d034746ad3f860758aa20e3c302deef5da0df29a6ab93f71dda90","created":1774517813,"exp":1777109813}
{"token":"a1ba2845684ab849d364a84027670c3c6357028b3d1afb5f5b4868e904805235","created":1756809404,"exp":1756895804} {"token":"4215cb8aa644120fce31c8bd9ff3cb3e100b6ae2f19c83bba9e1c6a87e7f4e99","created":1775642692,"exp":1778234692,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"6d2a7a1686cddb29441dfbe2e261d853ec55a1d46e1bbb8db3e4f8d0ce953937","created":1756809519,"exp":1756895919} {"token":"0441863e59bbc0bef45c1377ae31a7155c249cf298981dad3aaa7d9389635d95","created":1775643020,"exp":1778235020,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"1a0bc695ff5854e16b76dc60d5cbf51529942430b2d5315a36e0266136b9ab43","created":1756809545,"exp":1756895945} {"token":"d9813ed055fd974e92c92ffd2b9c64b86a572f4dfe0a075f6e68e4d057ae9629","created":1775643419,"exp":1778235419,"role":"coach","entityID":"f956a1c2239849b8924e9cf98da9c3da","userID":"3abc03d2bacc8e48fe62b6afc382d3a3"}
{"token":"375ace02859b27ca837bfa5a130e6237b7c7b39720ff5d880b3519e2c8bbff57","created":1756809577,"exp":1756895977} {"token":"b2277c780fb2c8ed218173c76bd7f36dce48bfb284862fdd04732fe84a9d3437","created":1775643440,"exp":1778235440,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"95c8f8cd5a300c2357fb237c55932dd15079d777c69ac9c523f347c98e6a196c","created":1756809590,"exp":1756895990} {"token":"ac2d9596f485f6f15051f267299c24aac60bd743f020c60f250d5086b1ae6dba","created":1775643458,"exp":1778235458,"role":"supervisor","entityID":"d4538fc39664f037eb8b0d4ceb8ec07e","userID":"9e23fa6f1ed02bef3a7405d15e866bb9"}
{"token":"66048cfaf8ba428c18125328d3d92ee5ef85fb5da4b57ca10e30ed49e65cb7a3","created":1756990520,"exp":1757076920} {"token":"3ae1ea99a01abedd3e2dfe6e2a4e8095a127353bf738ffb7baa0ca14b49b3ee6","created":1775659764,"exp":1778251764,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"74b33d1ec4314def382db73b25a68bb835b847399587b9e1c753b5fc6d7ac526","created":1756990626,"exp":1757077026} {"token":"324478b89a99a8b6987131b26d729364ef7fa7cadb51634fae160b12b052bd49","created":1775661148,"exp":1778253148,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"cf64baa593827d9d213f65da58f68e4be4fede9d1d089f667247985244218b79","created":1757055382,"exp":1757141782} {"token":"40032db80223b526499a7553ac4b58ecb0276b4aac8023444de1c674c07cc394","created":1775740664,"exp":1778332664,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"91161cab2fad81cc2fbd5bec8990bc763169b9151bd5686d5643a7df119ddbeb","created":1757057047,"exp":1757143447} {"token":"70417759cda569e3c11b6a443431e536d7eda73268e338ab40f7500d056083bb","created":1775741768,"exp":1778333768,"role":"supervisor","entityID":"d4538fc39664f037eb8b0d4ceb8ec07e","userID":"9e23fa6f1ed02bef3a7405d15e866bb9"}
{"token":"01390c7efae09a04917042c7a33ee669f1f399aea1ad6e38fdf0ea128cb56293","created":1757057152,"exp":1757143552} {"token":"f067dd4ca164b0fa66a87b299308390da0913da1b16cddad73c1b322272379b6","created":1775741791,"exp":1778333791,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"76da7ea81a475703f54e628d34349cf58825f5c69a83cd7ac28864d46a070f3e","created":1757057205,"exp":1757143605} {"token":"3b381d3013a68896662cad27196e77e4dd3dd0117a9ddf08922dbdff807d4fb8","created":1775741807,"exp":1778333807,"role":"supervisor","entityID":"d4538fc39664f037eb8b0d4ceb8ec07e","userID":"9e23fa6f1ed02bef3a7405d15e866bb9"}
{"token":"f39965dc4374d6e6a4ff1d7f3cfcebf8a46591496778de606d5e8be6583fc32b","created":1757057967,"exp":1757144367} {"token":"33cf888d8a443dd3055650a8962293e7251620d9a1a8a64a6a224611bc41d3b8","created":1775744411,"exp":1778336411,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"91d056c9e6c74d6cfb3a7dfc490e3fb74e0fa1107a644fa9ebc770046ad10108","created":1757058317,"exp":1757144717} {"token":"22f588244d7635bc6faf329a6291e91b3dd1ca301e2f2ec084a921e18a757ca4","created":1775744481,"exp":1778336481,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"a50ca166c451e4eb1a6ecfbd8e8947ab33adc41638cfe490844d4f216df1b2b7","created":1757058371,"exp":1757144771} {"token":"26c6ef3ebfbfd8f267f96a60bc4913504ade4b3edd0404668a2cf7af0dcb99e9","created":1775745540,"exp":1775746140,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847","temp":true}
{"token":"4fa698c244a21802e1001cd9e2ca95e3836f8a40b87130ba0bbf56e4409a5258","created":1757058387,"exp":1757144787} {"token":"fadf7a06ead066c607d5714546b6f4827bd21b3a4542e68386fb34ace4bcaa53","created":1775745548,"exp":1778337548,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"a0c28eaaf54cc6c85b0aa28abaf3579f7c730127f84782223b549eb69a4fbda2","created":1757315000,"exp":1757401400} {"token":"9fcea9c8d98fa09274dbbf6741bea50ab174a01b9b5d26138f933adf1f29957c","created":1775745732,"exp":1775746332,"role":"admin","entityID":"2069fffc7091928c70826b6498eccd7c","userID":"5fab912e0108e6b872b02c5d755cc893","temp":true}
{"token":"e03f93c020e7a1e63ca7bd35e8996effad1f164e771747248086e6e1062361a9","created":1757318539,"exp":1757404939} {"token":"20fbfb3a6f248a24ab33769a5e5de06732f402e0c9b9f709d17cc3b442c3b1ba","created":1775745738,"exp":1778337738,"role":"admin","entityID":"2069fffc7091928c70826b6498eccd7c","userID":"5fab912e0108e6b872b02c5d755cc893"}
{"token":"a2b06b8b6d4c4f13c0e1764b444c9d54b6391a44df11d76e07b388f010cef535","created":1757318594,"exp":1757404994} {"token":"e88d0746883123156433e55a5e07044e253f3429472df78e5d61f43c2c40e760","created":1775745809,"exp":1775746409,"role":"supervisor","entityID":"0032669cbb342c7c9937e48848e916b2","userID":"766621a2fce45e977f999926d9196e3d","temp":true}
{"token":"df8b9dc5ac814565c4fc7543d7dca8385103de68c3463c4a6bf751ec935a930e","created":1757319230,"exp":1757405630} {"token":"4cc8d74f0fe3d655e16c337612d02a68208620169c6d7ca587a25fddce61c004","created":1775745814,"exp":1778337814,"role":"supervisor","entityID":"0032669cbb342c7c9937e48848e916b2","userID":"766621a2fce45e977f999926d9196e3d"}
{"token":"ea183dbe572c64451825d0383554ec325494f79a342de8be082ca356bd634861","created":1757326842,"exp":1757413242} {"token":"2f5d7b59ccb55b21d4a5b7d7e03c9966df5c1377f447098ab398e9b789a6a188","created":1775746162,"exp":1778338162,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"f221237385a68cae4487815580ffde5dba191ab26e530629fbb9d25cfbd78b9b","created":1757328356,"exp":1757414756} {"token":"f1f2ba5d1a6e898688658006c8c42c0f6cb8a6e214f08d016cfc1a2a72af1e8e","created":1775747842,"exp":1778339842,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"1f1fbe3e4e61c93bf0dda1ecd1be15613d33a2065458789465ae6390df55bb6a","created":1758107596,"exp":1758193996} {"token":"02ded9879607050fc2531d8fb9265faadcc4c0a6ffced7a82eb919a0354f9c9c","created":1775804416,"exp":1778396416,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"9b36e1cd9064f7a0d0b6932d7e2540c8167b5f0217d25ecb3366890cbe45ad0b","created":1758107713,"exp":1758194113} {"token":"4923c95ed744e8eee5d4f215b16c2a1141ec49bca7f616651f124865e06e4e1b","created":1775835537,"exp":1778427537,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"c5a33030ac64f9695c8afde31dd5c11846e9047a2dea117f49a133f90ce76924","created":1758107738,"exp":1758194138}
{"token":"f2077115a4969f4f17d14b1245d93afee249b9dc224472ca9fb3626a88c767df","created":1758477721,"exp":1758564121}
{"token":"fb6e30161a17fcf4d26fff6b6fdf9390c516cda64dcb3df9d7785f22ff18b1d1","created":1758477745,"exp":1758564145}
{"token":"767d37875a3d3e26eed32a0856ab36e81b500155a2a86b0349b185ec7fb3a090","created":1758477764,"exp":1758564164}
{"token":"606140c6c1c72ee6ed237b9bcd1d06f8e0706d693be2a04eeae4e90830a5bd18","created":1758477789,"exp":1758564189}
{"token":"5dca3d7add783b6448d863809bcc4389578c4b8512fe3f6f24c7cdbef4e1b908","created":1758523992,"exp":1758610392}
{"token":"ecb9246a30d8c24398351fe69b40730d5fa3b1ccc237f822cc6ec568702bdc61","created":1758524058,"exp":1758610458}
{"token":"971f7fc5b21463b1180ce20152e8e9412e6582638f02d0d3f0ab5b87e831c92c","created":1758524093,"exp":1758610493}
{"token":"02ec7c0520bb2e9992012a276fac309770aead2d1e21e201142c88e85dd77938","created":1758524109,"exp":1758610509}
{"token":"b6844c5a99952786846d065777bb33647e24dd59371c6e16b37aa6fde8d08635","created":1758524126,"exp":1758610526}
{"token":"e07239755729bbd43f13112d25a3fd7ca652de6705e24fa49bfe9fbe67a94a43","created":1758524433,"exp":1758610833}
{"token":"0e0e946dbef1255217b9418adf577d79d195db8e2e1060932cc824ba6efaed11","created":1758524480,"exp":1758610880}
{"token":"6d2a941987d0753b8f58d3058039ebc091286030be9f0d6be08a666c6212f325","created":1758631441,"exp":1758717841}
{"token":"a2cc4bedf2d3a070546a795cd2799e0e6ba7a5640c31acc458909d7467fbacfd","created":1758631465,"exp":1758717865}
{"token":"5e5d72eb15330fc4dbfb3acf262d43c730307b04206234f22ddf7efa784857f4","created":1758631622,"exp":1758718022}
{"token":"83e5311d921f2d77a13b3bffc3173e3f1ab402de5a0983da046dac3735d50669","created":1758631641,"exp":1758718041}
{"token":"51835ba874245fb5fb31bf795ab418173a1ac842c50d0555464c61b452d0759b","created":1758631699,"exp":1758718099}
{"token":"f7d1a8f2625a4dc23caf983f78ca6e6cc09fb5a1c9e622ffc47d194bcb1eb2fc","created":1758631715,"exp":1758718115}
{"token":"f262efa480d0d98034e6139072f672a8c287454a415327472b8d8b76fc431bb5","created":1758631821,"exp":1758718221}
{"token":"b3fff94205e321bca2bfa80e5edfafa51a42e1e94f2f5813dc1c226d98e9a21f","created":1758631831,"exp":1758718231}
{"token":"10ba334c91ab6af3f8cb768cd6e139b3fecaf180a7c4a44ac8d6f25712e4a5a9","created":1758632063,"exp":1758718463}
{"token":"53b7527c88487d67a77e110b5274c72d5620088aae58debfdd68fd1b9583438a","created":1758632075,"exp":1758718475}
{"token":"a8e92c5cfcce9f4e4a3402a6b275230d759dd2f0c4de0577d237e5bdebdd7a39","created":1758632214,"exp":1758718614}
{"token":"93c49902b428c4073dd61e93fbd3b3267dc99710e0eace885362c1d36d1bff11","created":1758632231,"exp":1758718631}
{"token":"9bbbfbb833576b33140add4a3a251d5d5d4d130b82c95aa37e8d5f67b6b3fa47","created":1758634360,"exp":1758720760}
{"token":"3f6d20603b463bbff6369b75ed20c482c088d194604438af636a065cc00d38cc","created":1758634417,"exp":1758720817}
{"token":"16f4c983c3b186d282b5b68534e1d581e6aa2922c9d8fcecda9c4de9481d85ee","created":1758634669,"exp":1758721069}
{"token":"ac0e1352a82161ec19577823712b93f62c6eecc645248f8213d80ee5d056aef8","created":1758634694,"exp":1758721094}
{"token":"c44e10696b9de668527ee7e645cb44c7aba983a5890b71dd3fc923ebe93807b2","created":1758634748,"exp":1758721148}
{"token":"7547fd0a6593b28c4f4778ba93a00fa93e905b4d99aef38d4cd3215254659d1e","created":1758634779,"exp":1758721179}
{"token":"03c005ba089d5ca9db37dec87abcc87eda453a11c88cc9e0129c9c4420c66cd7","created":1758634837,"exp":1758721237}
{"token":"17a40e3d0752d7b40b1ecdff2fef13f1a670f027d2152eaf60146ce988cf8418","created":1758634850,"exp":1758721250}
{"token":"404a6ce228679470fd88b9926777238bbc4c3487dda478f5e9ba68cff9be24ba","created":1758637923,"exp":1758724323}
{"token":"9306963145c2beac257849cd41d8a8994899c04eb1661bb891b765d3eac4d202","created":1758638114,"exp":1758724514}
{"token":"02f79fd4f0d88863f6277552aa73a58f44d3706e2bc916f9bfa155b51047c124","created":1758638334,"exp":1758724734}
{"token":"d7722fecb0fcc0f4788fdbfd7c9d72dca05ee9417992426c3a0289dd605ea4cd","created":1758638870,"exp":1758725270}
{"token":"e8f527f42d8a27af0a8dfc1ab94811c9654327542ee6fbf56ed762677d06d24e","created":1758638916,"exp":1758725316}
{"token":"b8b66d92eba410abf7cde0d47528dca87526d0e6ebff12ec6d872ff56a5dfbda","created":1758638951,"exp":1758725351}
{"token":"f7c5cf139714a7d161fef9ff8de76a68131b1bebfc44ccbe2540fe9bd078f0ca","created":1758713063,"exp":1758799463}
{"token":"7b969f437896d654492db1f012bd15454f7d9a54547372a00d500e8575c18ebc","created":1758880572,"exp":1758966972}
{"token":"217f9fc39604e187023233900ea85c3cd6b0500b9a2e31f96b61b26b2b03757e","created":1758880587,"exp":1758966987}
{"token":"54fc17d45268b6653d8354d52a0a538eb62d1dd602e874aed5527f3d70ae9bc4","created":1758880618,"exp":1758967018}
{"token":"2f3f7fd350188548cbbd76959a91120bf67c871d8b0ce0d372a7394a6c958b49","created":1758880849,"exp":1758967249}
{"token":"76758ca57e42ed78504d148e243d9c333a83431b0072f59807e3ba906576c714","created":1758881595,"exp":1758967995}
{"token":"7543060413d38da66b7b0b80a444e8e37d3317c0be32585682365007ff6ce176","created":1758881763,"exp":1758968163}
{"token":"8ceff5d37ee6e6bd58b349fce81fd2d7814280799897e1f3ec94dc2c02b5044b","created":1758882150,"exp":1758968550}
{"token":"f813faa3aced8e2bb4ee7f815a0e5cd7c15cd8a314d178a9bd4adaa1d65ad7a8","created":1758963705,"exp":1759050105}
{"token":"30172769015f9d95aca3cff58dcc8e82dd1c34ff34e5fa421b93f852c2efc995","created":1758963970,"exp":1759050370}
{"token":"286f6448269afb14ad3579b2ec20edf5a5215efc7b8c61a4da42a81f670eea5a","created":1758964604,"exp":1759051004}
{"token":"602ba60c2b42590fd6b81fa0374769c3fa5419e6431ad936556c53f858e0dd3d","created":1758964964,"exp":1759051364}
{"token":"60d72e4dd79c595caa31a363a18fb65d53b408de657be558428486e0f95c1624","created":1759130219,"exp":1759216619}
{"token":"7ecf40a37b1860287ab9749039dd06b1aa41fc968daee96abb86b1f5640e9a3b","created":1759131060,"exp":1759217460}
{"token":"9f7caf0c8b690935664c8458297becfe0f01d4c636cc27739b6a4bb422dce023","created":1759131973,"exp":1759218373}
{"token":"49c9c4105eaae364566eb87e8ade6f76ee501fe304bc34a435ce5f59ba6d0d61","created":1759132236,"exp":1759218636}
{"token":"0bf4c2a9b0635fd0a9e9e6729f958c0c521ba03763740ffe35423d006382f3d2","created":1759132261,"exp":1759218661}
{"token":"4a227bacb48a669ed6f767cd5355bbb2b7ebdb8413439cd880debf8be6f05854","created":1759132276,"exp":1759218676}
{"token":"9f835e179b993f1ce748f3a8deffb5e2b6c420ba0ed256d76b1008bccb09592b","created":1759132305,"exp":1759218705}
{"token":"97726da2f384f90cc9772113d25b05f270d83bd95d8e7174217e0f1b90b6c0d8","created":1759132645,"exp":1759219045}
{"token":"12602dd163016ff3f4cc44e2024af559ab1d3ee4da03b1dd80b9c89bde3b7dac","created":1759132832,"exp":1759219232}
{"token":"555ce8248ee91024d3d78a29a001411643739d68bbd1b845331423cb03a2b686","created":1759132975,"exp":1759219375}
{"token":"23920ccf38cd1d89b8600be7634adf5b568dd65c55683d1aa9ae0065f07d9dd0","created":1759133185,"exp":1759219585}
{"token":"05223b7302ff2ce1920395b840dbaf901b03a19e74e586c89e557c7ecd5c045a","created":1759133317,"exp":1759219717}
{"token":"4bb6e11294e7c1231420ed75a9bb6cdd65c29b1af3a83e33663d41cb64b0db2e","created":1759133349,"exp":1759219749}
{"token":"c23f83b74435194855203c703a4c763b69fe3de6161e64528855887551edd777","created":1759133401,"exp":1759219801}
{"token":"48ca9bc7937f8ec478f97b8d77680a507f671ce02bae4c354ac66a924b4d9a63","created":1759133422,"exp":1759219822}
{"token":"e6952e1cb227a985cd8546ec8008f7d606ab329cf1d19bb148957a2bb794fa8f","created":1759133445,"exp":1759219845}
{"token":"fe1dfd9aa8bdc9717fd2dfbd3867771c979b5e49fab8f45342b8405e5dddfb8d","created":1759133762,"exp":1759220162}
{"token":"d62b8043df3bbdbdb787806c5b492ceaa505402d099ba38c78a46a409d0c33f0","created":1759133809,"exp":1759220209}
{"token":"6c78c2e05d9021ca8a050954104c9e0c31f76e944fdac5d56c7227dbba048f72","created":1759133839,"exp":1759220239}
{"token":"13ccd9f6051c1a11e6950fed92cd0c4e60cf595e47a07cbb25f283ceac7bbf43","created":1759133903,"exp":1759220303}
{"token":"0d9e3dc9e5aef1fcf579e6dc55bb989d84af7e27999295cb60900206a2b043e7","created":1759134295,"exp":1759220695}
{"token":"5248a0c841ae09db2166f1cd3254b1672031f321c71212e775b07794ee2efb99","created":1759136855,"exp":1759223255}
{"token":"dd9941e9b616fe179e33c0a884a3bfc3ada56c2f626afcb9da63e3e585ab9226","created":1759137413,"exp":1759223813}
{"token":"ecced21404122b68765272c3a1c5707be9ae230ab0cf193d661fc4b09bafe78d","created":1759137429,"exp":1759223829}
{"token":"8e013fbd30ee8278356f08e0aa0d3f6e4e709ae5c5246a731a8034070eda7079","created":1759137677,"exp":1759224077}
{"token":"b35841e30ea56d0bbf1274a7606294d1b85d149aebca4389675cb47731a25e1d","created":1759138841,"exp":1759225241}
{"token":"a82c1f1b051f51b33f1f3e676511a07f4914395c1fddce1aeee41aa0c398fa27","created":1759138863,"exp":1759225263}
{"token":"6647b9b2900320da05769955bdc172dabab24c6a24d258659593978d7b8d8be9","created":1759138920,"exp":1759225320}
{"token":"68b87e342ee484d6b9d278913ed8ca7864780e2672c520941eca7adcdabc90d0","created":1759138985,"exp":1759225385}
{"token":"41584f24f999a2b08bca7a26716b7bb82e27df6f3bf22efd925a05c1e49e7f4e","created":1759138997,"exp":1759225397}
{"token":"ee10e4e87b7c80c875e622d53fc1f3658b4d7e6d4cfb86a8b1af8a00537d76af","created":1759139042,"exp":1759225442}
{"token":"4a9fc9ad92631aa019e2f4a6c829bc47277bfad191ef7a0cff744b30949e5002","created":1759139881,"exp":1759226281}
{"token":"a3888918ee647f213e7408e6fff916e99f6fb3e757fe44a02f279b508da2016f","created":1759139945,"exp":1759226345}
{"token":"9b574f805ec4ab45a99aadf98246cc3cac3839cc25cc9f1f40ffc4a314cf5390","created":1759141376,"exp":1759227776}
{"token":"ef0d5340827ffc436c88863199d820cf7d8143f260c4f3f89a9438cbbae9e9ea","created":1759141871,"exp":1759228271}
{"token":"2d51a5ead5266958a15313b8dd92109d90ae0069c476328af7e764e3284e6563","created":1759141889,"exp":1759228289}
{"token":"01ce0e40ecd1253e53eaba374b09e363535fe511cea5c11a9cc7e2d50b5a5d70","created":1759141930,"exp":1759228330}
{"token":"d2a967c7ef1cfd6444f9f98865b71b7a50eee6fb8746b33f753bec23fe8e134c","created":1759141955,"exp":1759228355}
{"token":"31b442f6c1787dfc4090e8687d697bf3b9947acee107595054014892688b69fc","created":1759143187,"exp":1759229587}
{"token":"2cb95a73cd824886d5a8ef8f78e4ea50bec48545399b4ec8a97569e11ad1fae7","created":1759143242,"exp":1759229642}
{"token":"1270a387b5ed061801df25c587a32c16d19e01705bad9b33b5c8d081a8a6e405","created":1759665689,"exp":1759752089}
{"token":"cf99f7b82cf9d5929e171c4a10bfe2f804f8dbcfa8d4615032d116975273a20a","created":1759670447,"exp":1759756847}
{"token":"99a2398703836d3b4ad35122f1eceaaf752404a61df5932684f1bde2983065b0","created":1759671255,"exp":1759757655}
{"token":"492feb4f9c7cbdc9ff81003cf0ea821584ac9018d556ce64303e39f402644151","created":1759672995,"exp":1759759395}
{"token":"4950d4b18494285205bd5a1925d89c74a9a27d8bf61fea546a60c3ff77434c8a","created":1759769234,"exp":1759855634}
{"token":"d15b65be6c96d98d273bf75542da813d8bdf9a3e67b21b5d6e6ffc9578d825f6","created":1759769252,"exp":1759855652}
{"token":"7455244c86cfa13f6a35bb350669fb118ed559eec1b04615af6e61df24b15860","created":1759770329,"exp":1759856729}
{"token":"de71102f6b394bf1c9fefdabb3f81063947971d9c0b3039044f8a5ccb06fe0b9","created":1760019733,"exp":1760106133}
{"token":"33dfc2f32a92c33add844c063b6441435fbd81b099687f562f91bb844cce51fd","created":1760019746,"exp":1760106146}
{"token":"af9b3962b69dd2ed8101a6c82a4d2b3c16e6dcfe9bd2f704dad5ad89e5f4ec78","created":1760020288,"exp":1760106688}
{"token":"60476805d087f95b73bfc85906f3da2d564f0da823be997bff56840b9a3cbf83","created":1760020419,"exp":1760106819}
{"token":"e2865b9b9e93ab3a6824bbfc7b8a538d9504c5e742d8311ac8cff1cd5b1be537","created":1760020537,"exp":1760106937}
{"token":"e9525d496c03eab391ff7a33bebcf894e27739dbe72dbfd78561bd3f0abeeb04","created":1760020598,"exp":1760106998}
{"token":"3dab956d4162eafcafbc909774e5c72b8f705bd0118526ec64fc9aad779ffbd1","created":1760100224,"exp":1760186624}
{"token":"46048543edd7e1164f09e15ad7b270d0cbe902a8e9cf83c74b336126e9812919","created":1760100259,"exp":1760186659}
{"token":"c6769ca917c5b7bc3a8de449d4c1ee432a48af2816106d45151da78b6df5f6fe","created":1760102088,"exp":1760188488}
{"token":"1d661c5092d3f5cb49d9040eca309d899d76aab6853f730e10e11251aeb0e3e6","created":1760102101,"exp":1760188501}
{"token":"18678f9d4b37859266ce5f94974b50b8b79c6b2f8ba95b1127e21e55584242a1","created":1760102109,"exp":1760188509}
{"token":"4d0242faef13d10b3a601a221193e541eafaa4bc3ee6bd803c1ffb7a50839809","created":1760102145,"exp":1760188545}
{"token":"7b70d5aa0f414c658968462359345b2a3a14a9d4a5975b6e11120b474cda67ea","created":1760102318,"exp":1760188718}
{"token":"5cc22039581e43ee6ca998bddef9f38406074365ebab0f19f78a3770d129c80a","created":1760102851,"exp":1760189251}
{"token":"ca738bb4509efb56d9ed890202fe610e086e6c060a937fe24f0950d5e1ad0e91","created":1760102867,"exp":1760189267}
{"token":"63a52bf36dfdbdf183bc3645c0499c7b9d7407ff4e0b68d0ba4a7c4d21b3b2bd","created":1760373199,"exp":1760459599}
{"token":"540025ab075bd0b0d5b5f32136a2d728d33940428f1e03cb1cbefa2a4031cb22","created":1760373303,"exp":1760459703}
{"token":"698c35a7a528eff612691bd1e5b8d87606282e602946f4f3e780ad07882ccf39","created":1760376043,"exp":1760462443}
{"token":"03014ddfae73da00708075e584b7acc7453bd6fb09336ea4d0f311dea048cf74","created":1760423624,"exp":1760510024}
{"token":"59fe7551c92538ba16a8cb037cb4841e804955e541a711b93ce872c0cbfa4822","created":1760428837,"exp":1760515237}
{"token":"4e721a7c891ce14f50525807fefa7f30935fb646abcbe797a57967638d3153b1","created":1760429061,"exp":1760515461}
{"token":"df53f6ee07a698402ae87f2b61d0f1449eb9e8947379f8b5e49416577f44466e","created":1760429937,"exp":1760516337}
{"token":"72d697bda7b5b1b6c433f77d4974d056101cbfe59ed4a3b797b43e501e7315ae","created":1760602832,"exp":1760689232}
{"token":"f0f8bf32768a2741cc503b990d18123c5d4baf492e9a8d842eaaa1010ee5bb88","created":1760608588,"exp":1760694988}
{"token":"b7840ea72c124152e09992cc55bb8ea4239ec90fb5970caa2142344be49cd4c5","created":1760608677,"exp":1760695077}
{"token":"08aa5a790cc9fd8cef3b9a75b5b65286544c008dc2a9ea8f0d43b9e25fbe19a7","created":1760610683,"exp":1760697083}
{"token":"2cbc45e37c995d2673eb5b5d4f5dbc470e69b30b634972bf358c92afe841da06","created":1760610695,"exp":1760697095}
{"token":"bc6cf1156147f85d1b20982dd9f8730bf0fbf50b5740f62cf52bea5d5dd28c82","created":1760611508,"exp":1760697908}
{"token":"f1ff89caf2b5b3a68df5b091a9f158db2174f8e865f7d632cabe156c96847099","created":1760612940,"exp":1760699340}
{"token":"5cd4cc46d876fb7f6ac0435dd35f7b1f10637ef6aea0084cddcf701ee385747d","created":1760612996,"exp":1760699396}
{"token":"7d88c0fa36dc13981eacf5b57f36d666727cc516ef546a84e5708b0c47998175","created":1760613447,"exp":1760699847}
{"token":"627765059218d2e0f829887025a3c2c3319dbbfa052698ef4bbfa9932da34cd7","created":1760971357,"exp":1761057757}
{"token":"be417f82627bee9786ea5f8b99ce64a4dc855114f3e90e81192243bd7f9ee53c","created":1760972135,"exp":1761058535}
{"token":"4c4a6345cdd1a3a5556e44d2d306224eaf00ae580c46c4e0a305f9bf83ae1f1b","created":1761039024,"exp":1761125424}
{"token":"7b8def5d4ba5727c1332601b9652440c4850cfaa87cd93b38b5575b6ee2b93cc","created":1761064340,"exp":1761150740}
{"token":"33b38bbae91754d7459ea880a76f348765bc8c9a348ea32217efc40b487bcceb","created":1763290822,"exp":1763377222}
{"token":"9dde0839b583c875468530ee6fa2a92b85f4689bee30f59dd4e321fc2893cccd","created":1771865346,"exp":1771951746}
{"token":"cd7ef79a5664faf211e7508aa3e1da38a9295d3d7f30852abf87f657c4545ef3","created":1771865413,"exp":1771951813}
{"token":"3f71f2fde72c9fa670e4e17ac67da022952e5526fe3b4ffd70820046bab7dc7d","created":1771865429,"exp":1771951829}
{"token":"4eed29d1c55d2bcfc9fb218177062cd55f502ebca01915f121c4f725ab3886f3","created":1771865524,"exp":1771951924}
{"token":"508fdc8268cdbf8e58acbc6d0a2113f4f266f562ec2e7bbd2b4586226930bc1c","created":1771865748,"exp":1771952148}
{"token":"ea7c5c53ae3add1263435239a377f55a8edb3c3db0bb9d6ca8ca9755f40c952c","created":1771866052,"exp":1771952452}
{"token":"16db690623237f5f1e748c335ff72beb34be1ddf8117937705fc3f956d5854d4","created":1771866144,"exp":1771952544}
{"token":"7c079d53022f27743a8f41c2c16ba241622dde305d07df3da142feb5ce109a7a","created":1771866278,"exp":1771952678}
{"token":"afd3d79a917c910596bffd65ddd3d806f2c8c99f0ab9e398080989e0c352a3a0","created":1771866586,"exp":1771952986}
{"token":"e320bc456c9ab043f6259d687ff8074f0cd10ba63618f89671d584243b7be9e1","created":1771866962,"exp":1771953362}
{"token":"b37345318d18290039246d9473d0dcd64794ee2e9c024ecf6ec2c80a2d81820a","created":1771937505,"exp":1772023905}
{"token":"275398f85a005c7e58bdac6c5b001e544aee3cec9f5d323c4f98fe8578f39ae7","created":1771937569,"exp":1772023969}
{"token":"0476b9a7134403ab3fb28e0da8b00897a18bd1f20c58b3f66e49427f4e4fab17","created":1771939864,"exp":1772026264}
{"token":"6e4372fb37e0abd54d5a559978b1742bfbead965376df39439c3b809857dead9","created":1771940580,"exp":1772026980}
{"token":"dc58bb4fde7a94f169998ce31a1ac52433572b52f696d8af298dfe4191b3864c","created":1771940628,"exp":1772027028}
{"token":"e11133b3ab5c850ecc75a6496a882a7e7b348d4c449eba7d6192f598defdb229","created":1771940848,"exp":1772027248}
{"token":"44e8ac2a418d7b759c061d7f6187a0023736d2a0104b7ea70c1459ec8dab4700","created":1771941296,"exp":1772027696}
{"token":"9d39a349397f05b07b11fb1b07fe04d703030ed322d5f61bdf9b3dd81e244b59","created":1772000804,"exp":1772087204}
{"token":"b3e448c188ec718cf7aec6601fbd800cb087d08475ee95adc753eaab1a6ea800","created":1772001151,"exp":1772087551}
{"token":"9714b678caa1dded7222bb00ac466c8163bf1003dbaaf290d694be764e4a5c46","created":1772001535,"exp":1772087935}
{"token":"48721aaa13f78d571a28275e39fec81586eabda937fee765720ec72e42d82aa1","created":1772002283,"exp":1772088683}
{"token":"36242c20ed90459c72d12d7541311d803da304e3a50848a06ee47b0896543e9c","created":1772002329,"exp":1772088729}
{"token":"af278b2e4b05d94ff422dc4391e4aa1a5c09d43f3b34f1e3e522696ab631e07a","created":1772101322,"exp":1772187722}
{"token":"7abe6b6ffb9f168bcce197ee0e2287d9ee5055551d73aea70e479a8e4ef6ef01","created":1772106567,"exp":1772192967}
{"token":"88052fc72bf9ad9f99b70c4197322aa270dfef966e50a2ad268f62cbbae9e8d9","created":1772110997,"exp":1772197397}
{"token":"af85e2994c1d9e844d9179171e57ff3542de845967f1d1f4f90107029d814f20","created":1772111983,"exp":1772198383}
{"token":"3119ce71143809f78316ce131bc2e93bbf113b7b2a103fa20d584f233efc7a43","created":1772112290,"exp":1772198690}
{"token":"0474561405352eb731e66ed121a827c951673b7ff1b7032ba6195f9d19abde59","created":1772114237,"exp":1772200637}
{"token":"12719b1ce7992358d537eacb1d39397292acbc76fa12bde72c9d4314ae6f80dd","created":1772114779,"exp":1772201179}
{"token":"173d5f63ae00a092ebd135f0c09efe959bd23e426fb13159f8dea14f1dc4b924","created":1772283082,"exp":1772369482}
{"token":"8527a42eedef65315ed02f22f0a489279b5f8fe9f90a3a3e657fe4db3fdc5f48","created":1772452494,"exp":1772538894}
{"token":"8b9a0f52b37fa7dd28fc52ea8f6b6d40d60240dd978a1b78d1c295594836b954","created":1772698644,"exp":1772785044}
{"token":"d0bd7435c514c3f090d06ab92bc208b5d8eabd7cddd333427bd329257521a124","created":1772698738,"exp":1772785138}
{"token":"8f0959185b3c22aa86310192e8e81096c503305e211780a0e9a446711e762639","created":1772699719,"exp":1775291719}
{"token":"9bd42a9a3d9364f3087dd6e15b991daf764b4510cec728369e5b870f08ca91ad","created":1772703629,"exp":1775295629}

View File

@ -1,10 +1,7 @@
<?php <?php
// /var/www/html/uploadDeltaTest5.php // /var/www/html/uploadDeltaTest5.php
// Vollständige, eigenständige Version einfach kopieren & einfügen. require_once __DIR__ . '/db_init.php';
// -----------------------------
// Logging & PHP-Settings
// -----------------------------
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', '0'); ini_set('display_errors', '0');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
@ -15,99 +12,14 @@ function log_msg($msg) {
} }
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
// -----------------------------
// Krypto-/Token-Helfer
// -----------------------------
function b64_or_ascii_to_32_bytes(string $s): string {
// Versuche Base64, sonst ASCII; pad/truncate auf 32
$b = base64_decode($s, true);
if ($b !== false && $b !== '') {
return str_pad(substr($b, 0, 32), 32, "\0");
}
return str_pad(substr($s, 0, 32), 32, "\0");
}
function get_master_key_bytes(): string {
// MASTER-KEY nur serverseitig (für gespeicherte DB)
// ENV: QDB_MASTER_KEY (Base64 oder ASCII). Fallback ist alter Key.
$env = getenv('QDB_MASTER_KEY');
if ($env && $env !== '') return b64_or_ascii_to_32_bytes($env);
return "12345678901234567890123456789012"; // Fallback (bitte in PROD durch ENV ersetzen!)
}
function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes', int $len = 32): string {
// HKDF-SHA256: salt = 32x0x00, info = "qdb-aes"
$ikm = @hex2bin(trim($tokenHex));
if ($ikm === false) $ikm = $tokenHex; // falls bereits binär
$hashLen = 32;
$salt = str_repeat("\0", $hashLen);
$prk = hash_hmac('sha256', $ikm, $salt, true);
$okm = '';
$t = '';
$n = (int)ceil($len / $hashLen);
for ($i = 1; $i <= $n; $i++) {
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
$okm .= $t;
}
return substr($okm, 0, $len);
}
function aes256_cbc_encrypt_bytes(string $plain, string $key32): string {
$key = str_pad(substr($key32, 0, 32), 32, "\0");
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($cipher === false) throw new Exception('openssl_encrypt failed');
return $iv . $cipher;
}
function aes256_cbc_decrypt_bytes(string $data, string $key32): string {
if (strlen($data) < 16) throw new Exception('cipher too short');
$key = str_pad(substr($key32, 0, 32), 32, "\0");
$iv = substr($data, 0, 16);
$ct = substr($data, 16);
$plain = openssl_decrypt($ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($plain === false) throw new Exception('openssl_decrypt failed');
return $plain;
}
// Token-Validierung: bevorzugt tokens.jsonl mit exp, Fallback valid_tokens.txt
function token_is_valid(string $token): bool {
$now = time();
$jsonl = __DIR__ . '/tokens.jsonl';
if (file_exists($jsonl)) {
$h = fopen($jsonl, 'r');
if ($h) {
while (($line = fgets($h)) !== false) {
$j = json_decode($line, true);
if (!is_array($j)) continue;
if (($j['token'] ?? '') === $token) {
$exp = $j['exp'] ?? ($now + 1); // falls kein exp, akzeptieren
fclose($h);
return $exp >= $now;
}
}
fclose($h);
}
}
$txt = __DIR__ . '/valid_tokens.txt';
if (file_exists($txt)) {
$arr = file($txt, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($arr && in_array($token, $arr, true)) return true;
}
return false;
}
// -----------------------------
// Hauptlogik
// -----------------------------
try { try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); http_response_code(405);
echo json_encode(["error" => "Nur POST erlaubt"]); echo json_encode(["error" => "Only POST allowed"]);
exit; exit;
} }
// Token aus multipart oder Authorization: Bearer // Token from multipart field or Authorization: Bearer
$token = $_POST['token'] ?? null; $token = $_POST['token'] ?? null;
if (!$token) { if (!$token) {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? ''); $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
@ -115,13 +27,14 @@ try {
$token = substr($auth, 7); $token = substr($auth, 7);
} }
} }
if (!$token || !token_is_valid($token)) { $tokenRec = $token ? token_get_record($token) : null;
if (!$tokenRec || !empty($tokenRec['temp'])) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Ungültiger Token"]); echo json_encode(["error" => "Invalid token"]);
exit; exit;
} }
// Upload entgegennehmen (multipart 'file' oder raw body) // Upload: multipart 'file' or raw body
$uploadedFilePath = null; $uploadedFilePath = null;
if (!empty($_FILES['file']) && is_uploaded_file($_FILES['file']['tmp_name'])) { if (!empty($_FILES['file']) && is_uploaded_file($_FILES['file']['tmp_name'])) {
$uploadedFilePath = $_FILES['file']['tmp_name']; $uploadedFilePath = $_FILES['file']['tmp_name'];
@ -135,15 +48,15 @@ try {
} }
if (!$uploadedFilePath) { if (!$uploadedFilePath) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Keine Datei gesendet"]); echo json_encode(["error" => "No file sent"]);
exit; exit;
} }
// Payload mit SESSION-Key (aus Token via HKDF) entschlüsseln // Decrypt payload with session key derived from token via HKDF
$encData = file_get_contents($uploadedFilePath); $encData = file_get_contents($uploadedFilePath);
if ($encData === false) { if ($encData === false) {
http_response_code(500); http_response_code(500);
echo json_encode(["error" => "Fehler beim Lesen der hochgeladenen Datei"]); echo json_encode(["error" => "Failed to read uploaded file"]);
exit; exit;
} }
$sessionKey = hkdf_session_key_from_token($token); $sessionKey = hkdf_session_key_from_token($token);
@ -151,12 +64,11 @@ try {
try { try {
$jsonData = aes256_cbc_decrypt_bytes($encData, $sessionKey); $jsonData = aes256_cbc_decrypt_bytes($encData, $sessionKey);
} catch (Throwable $e) { } catch (Throwable $e) {
// Fallback: akzeptiere Plain-JSON (Kompatibilität)
$maybeJson = trim($encData); $maybeJson = trim($encData);
if ($maybeJson === '' || json_decode($maybeJson, true) === null) { if ($maybeJson === '' || json_decode($maybeJson, true) === null) {
log_msg("Entschlüsselung fehlgeschlagen und kein valides JSON. raw len=" . strlen($encData)); log_msg("Decryption failed and not valid JSON. raw len=" . strlen($encData));
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Ungültige verschlüsselte Datei oder kein JSON"]); echo json_encode(["error" => "Invalid encrypted file or not JSON"]);
exit; exit;
} }
$jsonData = $maybeJson; $jsonData = $maybeJson;
@ -165,177 +77,119 @@ try {
$data = json_decode($jsonData, true); $data = json_decode($jsonData, true);
if (!is_array($data)) { if (!is_array($data)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Ungültiges JSON"]); echo json_encode(["error" => "Invalid JSON"]);
exit; exit;
} }
// Ziel: MASTER-verschlüsselte DB speichern // RBAC: coaches can only upload for their own clients
$dbPath = __DIR__ . '/uploads/questionnaire_database'; $role = $tokenRec['role'] ?? '';
if (!is_dir(dirname($dbPath))) { $entityID = $tokenRec['entityID'] ?? '';
if (!mkdir(dirname($dbPath), 0755, true) && !is_dir(dirname($dbPath))) {
throw new Exception("Konnte Upload-Ordner nicht erstellen");
}
}
// Lock, damit keine parallelen Writes kollidieren [$pdo, $tmpDb, $lockFp] = qdb_open(true);
$lockFile = __DIR__ . '/uploads/.qdb_lock';
$lockFp = fopen($lockFile, 'c');
if ($lockFp === false) throw new Exception("Konnte Lock-Datei nicht öffnen");
if (!flock($lockFp, LOCK_EX)) { fclose($lockFp); throw new Exception("Konnte Lock nicht setzen"); }
$tmpDb = null;
$tmpEncrypted = null;
try { try {
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_'); $pdo->beginTransaction();
$masterKey = get_master_key_bytes();
// Bestehende MASTER-verschlüsselte DB entschlüsseln oder neue DB mit Schema anlegen if ($role === 'coach' && !empty($data['client'])) {
if (file_exists($dbPath) && is_file($dbPath)) { foreach ($data['client'] as $c) {
$storedEnc = @file_get_contents($dbPath);
if ($storedEnc === false) throw new Exception("Konnte gespeicherte DB nicht lesen");
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
if (file_put_contents($tmpDb, $decrypted) === false) throw new Exception("Konnte temporäre DB nicht schreiben");
} else {
$pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("PRAGMA foreign_keys = ON;");
$pdo->exec("
CREATE TABLE IF NOT EXISTS clients (
clientCode TEXT NOT NULL DEFAULT 'undefined' PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS questionnaires (
id TEXT NOT NULL DEFAULT 'undefined' PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS questions (
questionId TEXT NOT NULL DEFAULT 'undefined' PRIMARY KEY,
questionnaireId TEXT NOT NULL,
question TEXT NOT NULL DEFAULT '',
FOREIGN KEY(questionnaireId) REFERENCES questionnaires(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS answers (
clientCode TEXT NOT NULL,
questionId TEXT NOT NULL,
answerValue TEXT NOT NULL DEFAULT '',
PRIMARY KEY (clientCode, questionId),
FOREIGN KEY(clientCode) REFERENCES clients(clientCode) ON DELETE CASCADE,
FOREIGN KEY(questionId) REFERENCES questions(questionId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS completed_questionnaires (
clientCode TEXT NOT NULL,
questionnaireId TEXT NOT NULL,
timestamp INTEGER NOT NULL,
isDone INTEGER NOT NULL,
sumPoints INTEGER,
PRIMARY KEY (clientCode, questionnaireId),
FOREIGN KEY(clientCode) REFERENCES clients(clientCode) ON DELETE CASCADE,
FOREIGN KEY(questionnaireId) REFERENCES questionnaires(id) ON DELETE CASCADE
);
");
$pdo = null;
}
// Einspielen der Daten
$db = new PDO("sqlite:$tmpDb");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("PRAGMA foreign_keys = ON;");
$db->beginTransaction();
// --- Sammeln, welche IDs/Paarungen betroffen sind ---
$questionnaireIds = []; // set[string] questionnaireId => true
$clientQuestionPairs = []; // set["client|qid"] => true
if (!empty($data['questionnaires'])) {
foreach ($data['questionnaires'] as $q) {
if (isset($q['id']) && $q['id'] !== null && $q['id'] !== '') {
$questionnaireIds[$q['id']] = true;
}
}
}
if (!empty($data['questions'])) {
foreach ($data['questions'] as $q) {
if (isset($q['questionnaireId']) && $q['questionnaireId'] !== null && $q['questionnaireId'] !== '') {
$questionnaireIds[$q['questionnaireId']] = true;
}
}
}
if (!empty($data['completed_questionnaires'])) {
foreach ($data['completed_questionnaires'] as $c) {
if (isset($c['questionnaireId']) && $c['questionnaireId'] !== null && $c['questionnaireId'] !== '') {
$questionnaireIds[$c['questionnaireId']] = true;
}
if (isset($c['clientCode']) && isset($c['questionnaireId'])) {
$client = ($c['clientCode'] === null || $c['clientCode'] === '') ? 'undefined' : $c['clientCode'];
$qid = ($c['questionnaireId'] === null || $c['questionnaireId'] === '') ? 'undefined' : $c['questionnaireId'];
$clientQuestionPairs["$client|$qid"] = true;
}
}
}
if (!empty($data['answers'])) {
foreach ($data['answers'] as $a) {
if (isset($a['clientCode'])) {
$client = ($a['clientCode'] === null || $a['clientCode'] === '') ? 'undefined' : $a['clientCode'];
$qid = null;
if (isset($a['questionId']) && is_string($a['questionId'])) {
$parts = explode('-', $a['questionId'], 2);
if (count($parts) >= 1) {
$potential = $parts[0];
if ($potential !== '') $qid = $potential;
}
}
if ($qid !== null) {
$questionnaireIds[$qid] = true;
$clientQuestionPairs["$client|$qid"] = true;
}
}
}
}
if (!empty($data['clients']) && !empty($data['questionnaires'])) {
foreach ($data['clients'] as $c) {
if (!isset($c['clientCode'])) continue; if (!isset($c['clientCode'])) continue;
$client = ($c['clientCode'] === null || $c['clientCode'] === '') ? 'undefined' : $c['clientCode']; $chk = $pdo->prepare("SELECT coachID FROM client WHERE clientCode = :cc");
foreach ($data['questionnaires'] as $q) { $chk->execute([':cc' => $c['clientCode']]);
if (!isset($q['id'])) continue; $existing = $chk->fetch(PDO::FETCH_ASSOC);
$qid = ($q['id'] === null || $q['id'] === '') ? 'undefined' : $q['id']; if ($existing && $existing['coachID'] !== $entityID) {
$clientQuestionPairs["$client|$qid"] = true; $pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode(["error" => "Not authorized to modify client " . $c['clientCode']]);
exit;
} }
} }
} }
// --- Löschungen (zuerst abhängige Tabellen) --- // --- Collect affected IDs ---
$delAnswersByPairStmt = $db->prepare(" $questionnaireIds = [];
DELETE FROM answers $clientQuestionPairs = [];
WHERE clientCode = :clientCode
AND questionId IN (SELECT questionId FROM questions WHERE questionnaireId = :questionnaireId) if (!empty($data['questionnaire'])) {
"); foreach ($data['questionnaire'] as $q) {
$delCompletedByPairStmt = $db->prepare(" if (!empty($q['questionnaireID'])) $questionnaireIds[$q['questionnaireID']] = true;
DELETE FROM completed_questionnaires }
WHERE clientCode = :clientCode }
AND questionnaireId = :questionnaireId if (!empty($data['question'])) {
"); foreach ($data['question'] as $q) {
if (!empty($q['questionnaireID'])) $questionnaireIds[$q['questionnaireID']] = true;
}
}
if (!empty($data['completed_questionnaire'])) {
foreach ($data['completed_questionnaire'] as $c) {
if (!empty($c['questionnaireID'])) $questionnaireIds[$c['questionnaireID']] = true;
if (isset($c['clientCode'], $c['questionnaireID'])) {
$cc = $c['clientCode'] ?: 'undefined';
$qid = $c['questionnaireID'] ?: 'undefined';
$clientQuestionPairs["$cc|$qid"] = true;
}
}
}
if (!empty($data['client_answer'])) {
foreach ($data['client_answer'] as $a) {
if (!isset($a['clientCode'])) continue;
$cc = $a['clientCode'] ?: 'undefined';
if (isset($a['questionID']) && is_string($a['questionID'])) {
$parts = explode('-', $a['questionID'], 2);
if ($parts[0] !== '') {
$questionnaireIds[$parts[0]] = true;
$clientQuestionPairs["$cc|{$parts[0]}"] = true;
}
}
}
}
if (!empty($data['client']) && !empty($data['questionnaire'])) {
foreach ($data['client'] as $c) {
if (!isset($c['clientCode'])) continue;
$cc = $c['clientCode'] ?: 'undefined';
foreach ($data['questionnaire'] as $q) {
if (!isset($q['questionnaireID'])) continue;
$qid = $q['questionnaireID'] ?: 'undefined';
$clientQuestionPairs["$cc|$qid"] = true;
}
}
}
// --- Deletions (dependents first) ---
$delAnswersByPairStmt = $pdo->prepare(
"DELETE FROM client_answer
WHERE clientCode = :cc
AND questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qid)"
);
$delCompletedByPairStmt = $pdo->prepare(
"DELETE FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qid"
);
foreach ($clientQuestionPairs as $pair => $_) { foreach ($clientQuestionPairs as $pair => $_) {
list($clientCode, $qid) = explode('|', $pair, 2); [$cc, $qid] = explode('|', $pair, 2);
try { try {
$delAnswersByPairStmt->execute([ $delAnswersByPairStmt->execute([':cc' => $cc, ':qid' => $qid]);
':clientCode' => $clientCode, $delCompletedByPairStmt->execute([':cc' => $cc, ':qid' => $qid]);
':questionnaireId' => $qid log_msg("Deleted answers & completed for client='$cc' questionnaire='$qid'");
]);
$delCompletedByPairStmt->execute([
':clientCode' => $clientCode,
':questionnaireId' => $qid
]);
log_msg("Deleted answers & completed for client='$clientCode' questionnaire='$qid'");
} catch (Exception $e) { } catch (Exception $e) {
log_msg("Delete error for pair $pair: " . $e->getMessage()); log_msg("Delete error for pair $pair: " . $e->getMessage());
} }
} }
if (!empty($questionnaireIds)) { if (!empty($questionnaireIds)) {
$delQuestionsStmt = $db->prepare("DELETE FROM questions WHERE questionnaireId = :questionnaireId"); $delAOStmt = $pdo->prepare(
"DELETE FROM answer_option WHERE questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qid)"
);
$delQTransStmt = $pdo->prepare(
"DELETE FROM question_translation WHERE questionID IN (SELECT questionID FROM question WHERE questionnaireID = :qid)"
);
$delQuestionsStmt = $pdo->prepare("DELETE FROM question WHERE questionnaireID = :qid");
foreach ($questionnaireIds as $qid => $_) { foreach ($questionnaireIds as $qid => $_) {
try { try {
$delQuestionsStmt->execute([':questionnaireId' => $qid]); $delAOStmt->execute([':qid' => $qid]);
log_msg("Deleted questions for questionnaireId='$qid'"); $delQTransStmt->execute([':qid' => $qid]);
$delQuestionsStmt->execute([':qid' => $qid]);
log_msg("Deleted questions/options/translations for questionnaireID='$qid'");
} catch (Exception $e) { } catch (Exception $e) {
log_msg("Delete questions error for $qid: " . $e->getMessage()); log_msg("Delete questions error for $qid: " . $e->getMessage());
} }
@ -343,124 +197,160 @@ try {
} }
// --- Inserts/Upserts --- // --- Inserts/Upserts ---
if (!empty($data['clients'])) { if (!empty($data['client'])) {
$stmt = $db->prepare("INSERT OR IGNORE INTO clients (clientCode) VALUES (:clientCode)"); $stmt = $pdo->prepare(
foreach ($data['clients'] as $client) { "INSERT OR REPLACE INTO client (clientCode, coachID) VALUES (:cc, :cid)"
if (!isset($client['clientCode'])) continue; );
$val = $client['clientCode'] === null || $client['clientCode'] === '' ? 'undefined' : $client['clientCode']; foreach ($data['client'] as $c) {
$stmt->execute([':clientCode' => $val]); if (!isset($c['clientCode'])) continue;
$cc = $c['clientCode'] ?: 'undefined';
$cid = $c['coachID'] ?? $entityID;
$stmt->execute([':cc' => $cc, ':cid' => $cid]);
} }
} }
if (!empty($data['questionnaires'])) { if (!empty($data['questionnaire'])) {
$stmt = $db->prepare("INSERT OR IGNORE INTO questionnaires (id) VALUES (:id)"); $stmt = $pdo->prepare(
foreach ($data['questionnaires'] as $q) { "INSERT OR REPLACE INTO questionnaire (questionnaireID, name, version, state)
if (!isset($q['id'])) continue; VALUES (:qid, :name, :ver, :state)"
$val = $q['id'] === null || $q['id'] === '' ? 'undefined' : $q['id']; );
$stmt->execute([':id' => $val]); foreach ($data['questionnaire'] as $q) {
} if (!isset($q['questionnaireID'])) continue;
}
if (!empty($data['questions'])) {
$stmt = $db->prepare("
INSERT OR IGNORE INTO questions (questionId, questionnaireId, question)
VALUES (:questionId, :questionnaireId, :question)
");
foreach ($data['questions'] as $q) {
if (!isset($q['questionId'])) continue;
$questionId = $q['questionId'] === null || $q['questionId'] === '' ? 'undefined' : $q['questionId'];
$questionnaireId = $q['questionnaireId'] === null || $q['questionnaireId'] === '' ? 'undefined' : $q['questionnaireId'];
$questionText = $q['question'] ?? '';
$stmt->execute([ $stmt->execute([
':questionId' => $questionId, ':qid' => $q['questionnaireID'] ?: 'undefined',
':questionnaireId' => $questionnaireId, ':name' => $q['name'] ?? '',
':question' => $questionText ':ver' => $q['version'] ?? '',
':state' => $q['state'] ?? '',
]); ]);
} }
} }
if (!empty($data['answers'])) { if (!empty($data['question'])) {
$stmt = $db->prepare(" $stmt = $pdo->prepare(
INSERT OR IGNORE INTO answers (clientCode, questionId, answerValue) "INSERT OR IGNORE INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired)
VALUES (:clientCode, :questionId, :answerValue) VALUES (:qid, :qqid, :dt, :t, :oi, :ir)"
"); );
foreach ($data['answers'] as $a) { foreach ($data['question'] as $q) {
if (!isset($a['clientCode']) || !isset($a['questionId'])) continue; if (!isset($q['questionID'])) continue;
$clientCode = $a['clientCode'] === null || $a['clientCode'] === '' ? 'undefined' : $a['clientCode'];
$questionId = $a['questionId'] === null || $a['questionId'] === '' ? 'undefined' : $a['questionId'];
$answerValue = array_key_exists('answerValue', $a) && $a['answerValue'] !== null ? $a['answerValue'] : '';
$stmt->execute([ $stmt->execute([
':clientCode' => $clientCode, ':qid' => $q['questionID'] ?: 'undefined',
':questionId' => $questionId, ':qqid' => $q['questionnaireID'] ?? 'undefined',
':answerValue' => $answerValue ':dt' => $q['defaultText'] ?? '',
':t' => $q['type'] ?? '',
':oi' => (int)($q['orderIndex'] ?? 0),
':ir' => (int)($q['isRequired'] ?? 0),
]); ]);
} }
} }
if (!empty($data['completed_questionnaires'])) { if (!empty($data['answer_option'])) {
$stmt = $db->prepare(" $stmt = $pdo->prepare(
INSERT OR REPLACE INTO completed_questionnaires (clientCode, questionnaireId, timestamp, isDone, sumPoints) "INSERT OR IGNORE INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex)
VALUES (:clientCode, :questionnaireId, :timestamp, :isDone, :sumPoints) VALUES (:aoid, :qid, :dt, :pts, :oi)"
"); );
foreach ($data['completed_questionnaires'] as $c) { foreach ($data['answer_option'] as $ao) {
if (!isset($c['clientCode']) || !isset($c['questionnaireId'])) continue; if (!isset($ao['answerOptionID'])) continue;
$clientCode = $c['clientCode'] === null || $c['clientCode'] === '' ? 'undefined' : $c['clientCode'];
$questionnaireId = $c['questionnaireId'] === null || $c['questionnaireId'] === '' ? 'undefined' : $c['questionnaireId'];
$timestamp = isset($c['timestamp']) ? (int)$c['timestamp'] : time();
$isDone = !empty($c['isDone']) ? 1 : 0;
$sumPoints = array_key_exists('sumPoints', $c) ? ($c['sumPoints'] === null ? null : (int)$c['sumPoints']) : null;
$stmt->execute([ $stmt->execute([
':clientCode' => $clientCode, ':aoid' => $ao['answerOptionID'],
':questionnaireId' => $questionnaireId, ':qid' => $ao['questionID'] ?? 'undefined',
':timestamp' => $timestamp, ':dt' => $ao['defaultText'] ?? '',
':isDone' => $isDone, ':pts' => (int)($ao['points'] ?? 0),
':sumPoints' => $sumPoints ':oi' => (int)($ao['orderIndex'] ?? 0),
]); ]);
} }
} }
$db->commit(); if (!empty($data['client_answer'])) {
$db = null; $stmt = $pdo->prepare(
"INSERT OR REPLACE INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
// MASTER-verschlüsselt speichern (atomar via temp + rename) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)"
$plainDb = file_get_contents($tmpDb); );
if ($plainDb === false) throw new Exception("Konnte tmp DB nicht lesen"); foreach ($data['client_answer'] as $a) {
$enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey); if (!isset($a['clientCode'], $a['questionID'])) continue;
$stmt->execute([
$tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_'); ':cc' => $a['clientCode'] ?: 'undefined',
if (file_put_contents($tmpEncrypted, $enc) === false) throw new Exception("Konnte verschlüsselte DB nicht schreiben"); ':qid' => $a['questionID'] ?: 'undefined',
if (!@rename($tmpEncrypted, $dbPath)) { ':aoid' => $a['answerOptionID'] ?? null,
if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) { ':ftv' => $a['freeTextValue'] ?? null,
throw new Exception("Konnte verschlüsselte DB nicht speichern (rename/copy fehlgeschlagen)"); ':nv' => isset($a['numericValue']) ? (float)$a['numericValue'] : null,
':at' => isset($a['answeredAt']) ? (int)$a['answeredAt'] : null,
]);
} }
} }
@chmod($dbPath, 0644);
// Cleanup if (!empty($data['completed_questionnaire'])) {
$stmt = $pdo->prepare(
"INSERT OR REPLACE INTO completed_questionnaire
(clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
VALUES (:cc, :qid, :abc, :st, :sa, :ca, :sp)"
);
foreach ($data['completed_questionnaire'] as $c) {
if (!isset($c['clientCode'], $c['questionnaireID'])) continue;
$stmt->execute([
':cc' => $c['clientCode'] ?: 'undefined',
':qid' => $c['questionnaireID'] ?: 'undefined',
':abc' => $c['assignedByCoach'] ?? null,
':st' => $c['status'] ?? '',
':sa' => isset($c['startedAt']) ? (int)$c['startedAt'] : null,
':ca' => isset($c['completedAt']) ? (int)$c['completedAt'] : null,
':sp' => isset($c['sumPoints']) ? (int)$c['sumPoints'] : null,
]);
}
}
if (!empty($data['question_translation'])) {
$stmt = $pdo->prepare(
"INSERT OR REPLACE INTO question_translation (questionID, languageCode, text)
VALUES (:qid, :lc, :txt)"
);
foreach ($data['question_translation'] as $qt) {
if (!isset($qt['questionID'], $qt['languageCode'])) continue;
$stmt->execute([
':qid' => $qt['questionID'],
':lc' => $qt['languageCode'],
':txt' => $qt['text'] ?? '',
]);
}
}
if (!empty($data['answer_option_translation'])) {
$stmt = $pdo->prepare(
"INSERT OR REPLACE INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:aoid, :lc, :txt)"
);
foreach ($data['answer_option_translation'] as $aot) {
if (!isset($aot['answerOptionID'], $aot['languageCode'])) continue;
$stmt->execute([
':aoid' => $aot['answerOptionID'],
':lc' => $aot['languageCode'],
':txt' => $aot['text'] ?? '',
]);
}
}
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
@unlink($uploadedFilePath); @unlink($uploadedFilePath);
@unlink($tmpDb);
flock($lockFp, LOCK_UN);
fclose($lockFp);
echo json_encode(["success" => true, "message" => "Delta erfolgreich eingespielt und DB gespeichert"]); echo json_encode(["success" => true, "message" => "Delta applied and DB saved"]);
exit; exit;
} catch (Throwable $inner) { } catch (Throwable $inner) {
// Cleanup bei Fehler qdb_discard($tmpDb, $lockFp);
@flock($lockFp, LOCK_UN);
@fclose($lockFp);
@unlink($tmpDb ?? '');
@unlink($tmpEncrypted ?? '');
@unlink($uploadedFilePath ?? ''); @unlink($uploadedFilePath ?? '');
log_msg("Inner exception: " . $inner->getMessage()); log_msg("Inner exception: " . $inner->getMessage());
http_response_code(500); http_response_code(500);
echo json_encode(["error" => "Fehler beim Speichern", "message" => $inner->getMessage()]); error_log($inner->getMessage());
echo json_encode(["error" => "Save error"]);
exit; exit;
} }
} catch (Throwable $e) { } catch (Throwable $e) {
log_msg("Top-level exception: " . $e->getMessage()); log_msg("Top-level exception: " . $e->getMessage());
http_response_code(500); http_response_code(500);
echo json_encode(["error" => "Fehler", "message" => $e->getMessage()]); error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
exit; exit;
} }

Binary file not shown.

Binary file not shown.

945
website/css/style.css Normal file
View File

@ -0,0 +1,945 @@
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #dc2626;
--danger-hover: #b91c1c;
--success: #16a34a;
--warning: #d97706;
--text: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
--shadow-lg: 0 4px 12px rgba(0,0,0,.1);
--sidebar-width: 240px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
/* Layout */
.layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
}
.sidebar-brand {
padding: 20px;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 1.1rem;
}
.sidebar-brand small {
display: block;
font-weight: 400;
font-size: .75rem;
color: var(--text-secondary);
}
.sidebar-nav {
flex: 1;
padding: 12px 0;
list-style: none;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--text-secondary);
text-decoration: none;
font-size: .9rem;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover,
.sidebar-nav a.active {
background: #f1f5f9;
color: var(--primary);
}
.sidebar-nav a svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
font-size: .85rem;
}
.sidebar-footer .user-info {
color: var(--text-secondary);
margin-bottom: 8px;
}
/* Main content */
.main-content {
margin-left: var(--sidebar-width);
flex: 1;
padding: 28px 32px;
min-width: 0;
}
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: .875rem;
font-weight: 500;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: background .15s, box-shadow .15s;
text-decoration: none;
line-height: 1.4;
}
.btn:hover { background: #f8fafc; box-shadow: var(--shadow); }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-hover); }
.btn-sm { padding: 4px 10px; font-size: .8rem; }
.btn-block { width: 100%; justify-content: center; }
.btn-icon { padding: 6px; border: none; background: transparent; }
.btn-icon:hover { background: #f1f5f9; border-radius: 4px; }
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: .85rem;
font-weight: 500;
margin-bottom: 4px;
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .9rem;
font-family: var(--font);
background: var(--surface);
color: var(--text);
transition: border-color .15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
}
.form-row {
display: flex;
gap: 12px;
}
.form-row .form-group { flex: 1; }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
font-size: .75rem;
font-weight: 600;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: .03em;
}
.badge-draft { background: #f1f5f9; color: #64748b; }
.badge-active { background: #dcfce7; color: #16a34a; }
.badge-archived { background: #fef3c7; color: #d97706; }
.badge-completed { background: #dcfce7; color: #16a34a; }
.badge-in_progress { background: #dbeafe; color: #2563eb; }
.badge-pending { background: #f1f5f9; color: #64748b; }
/* Page header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.page-header .actions {
display: flex;
gap: 8px;
}
/* Questionnaire card grid */
.q-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.q-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
transition: box-shadow .2s, transform .2s;
cursor: pointer;
}
.q-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.q-card-header {
display: flex;
align-items: start;
justify-content: space-between;
margin-bottom: 12px;
}
.q-card-title {
font-size: 1.05rem;
font-weight: 600;
}
.q-card-meta {
display: flex;
gap: 16px;
color: var(--text-secondary);
font-size: .85rem;
}
.q-card-actions {
display: flex;
gap: 6px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
table.data-table th,
table.data-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
table.data-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .04em;
background: #f8fafc;
position: sticky;
top: 0;
z-index: 1;
}
table.data-table tr:hover td {
background: #f8fafc;
}
th.sortable { cursor: pointer; user-select: none; }
th.sortable::after { content: ' \2195'; opacity: .4; }
th.sort-asc::after { content: ' \2191'; opacity: 1; }
th.sort-desc::after { content: ' \2193'; opacity: 1; }
/* Accordion / question list */
.question-list {
list-style: none;
}
.question-item {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
background: var(--surface);
}
.question-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
cursor: pointer;
user-select: none;
}
.question-header:hover { background: #f8fafc; }
.drag-handle {
cursor: grab;
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1;
}
.drag-handle:active { cursor: grabbing; }
.question-header .q-text {
flex: 1;
font-weight: 500;
}
.question-header .q-type {
font-size: .8rem;
color: var(--text-secondary);
background: #f1f5f9;
padding: 2px 8px;
border-radius: 4px;
}
.question-body {
display: none;
padding: 0 16px 16px 42px;
}
.question-item.open .question-body {
display: block;
}
.question-item.open .chevron {
transform: rotate(90deg);
}
.chevron {
transition: transform .2s;
font-size: .8rem;
color: var(--text-secondary);
}
/* Answer options inside a question */
.option-list {
list-style: none;
margin-top: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 4px;
background: #fafbfc;
font-size: .875rem;
}
.option-item .opt-text { flex: 1; }
.option-item .opt-points {
font-weight: 600;
color: var(--primary);
font-size: .8rem;
background: #eff6ff;
padding: 1px 6px;
border-radius: 4px;
}
/* Translations panel */
.translations-panel {
margin-top: 12px;
padding: 12px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid var(--border);
}
.translations-panel h4 {
font-size: .85rem;
margin-bottom: 8px;
color: var(--text-secondary);
}
.translation-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.translation-row input[type="text"] {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: .85rem;
}
.translation-row .lang-code {
font-weight: 600;
font-size: .8rem;
width: 30px;
text-transform: uppercase;
color: var(--text-secondary);
}
/* Inline edit */
.inline-edit {
border: 1px solid transparent;
background: transparent;
padding: 2px 4px;
font: inherit;
border-radius: 4px;
width: 100%;
transition: border-color .15s;
}
.inline-edit:hover { border-color: var(--border); }
.inline-edit:focus {
outline: none;
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(37,99,235,.1);
}
/* Login */
.login-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-card h1 { font-size: 1.5rem; margin-bottom: 4px; }
.login-card .subtitle {
color: var(--text-secondary);
font-size: .9rem;
margin-bottom: 24px;
}
.error-text { color: var(--danger); font-size: .85rem; margin-top: 8px; }
/* Toast */
#toastContainer {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 20px;
border-radius: 8px;
font-size: .875rem;
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translateX(40px);
transition: opacity .3s, transform .3s;
max-width: 360px;
}
.toast.show { opacity: 1; transform: translateX(0); }
.toast-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
.toast-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
.toast-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
/* Filter bar */
.filter-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-bar select,
.filter-bar input {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .85rem;
background: var(--surface);
}
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg { margin-bottom: 16px; opacity: .4; }
.empty-state h3 { margin-bottom: 8px; color: var(--text); }
/* Dragging */
.question-item.dragging {
opacity: .5;
border-style: dashed;
}
.option-item.dragging {
opacity: .5;
border-style: dashed;
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Responsive */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform .3s;
}
.sidebar.open { transform: translateX(0); }
.main-content { margin-left: 0; padding: 16px; }
.q-grid { grid-template-columns: 1fr; }
}
/* Role badges */
.badge-admin { background: #ede9fe; color: #6d28d9; }
.badge-supervisor { background: #fce7f3; color: #be185d; }
.badge-coach { background: #d1fae5; color: #065f46; }
.badge-you { background: #dbeafe; color: #1d4ed8; font-size: .7rem; }
/* Assignment page layout */
.assign-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) {
.assign-layout { grid-template-columns: 1fr; }
}
.assign-coaches { padding: 16px; }
.assign-right { display: flex; flex-direction: column; gap: 16px; }
.assign-clients-card { padding: 16px; }
.assign-action-card { padding: 16px; }
/* Coach list */
.coach-list { list-style: none; }
.coach-list-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background .15s;
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid transparent;
margin-bottom: 4px;
}
.coach-list-item:hover { background: #f1f5f9; }
.coach-list-item.active {
background: #eff6ff;
border-color: var(--primary);
}
.coach-name { font-weight: 600; font-size: .9rem; }
.coach-supervisor { font-size: .8rem; color: var(--text-secondary); }
.coach-client-count {
font-size: .75rem;
color: var(--primary);
font-weight: 500;
margin-top: 2px;
}
/* Selection info bar */
.selection-info {
margin-top: 10px;
padding: 8px 12px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
font-size: .875rem;
color: #1e40af;
}
/* Tabs */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
}
.tab-bar button {
padding: 10px 20px;
font-size: .9rem;
font-weight: 500;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
color: var(--text-secondary);
transition: color .15s, border-color .15s;
}
.tab-bar button:hover { color: var(--text); }
.tab-bar button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Metadata form in editor */
.meta-form {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 600px) {
.meta-form { grid-template-columns: 1fr; }
}
/* Inline form cards (add/edit question, add/edit option) */
.inline-form-card {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.inline-form-card h4 {
font-size: .9rem;
font-weight: 600;
margin-bottom: 12px;
}
.inline-form-card .form-group { margin-bottom: 10px; }
/* Type select */
.type-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .9rem;
font-family: var(--font);
background: var(--surface);
color: var(--text);
}
.type-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
}
/* Checkbox label */
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: .875rem;
cursor: pointer;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
/* Options builder (inside add-question form) */
.options-builder {
border-top: 1px solid var(--border);
margin-top: 12px;
padding-top: 12px;
}
/* Pending options list (before question is saved) */
.pending-options-list {
list-style: none;
margin-bottom: 6px;
}
.pending-option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 4px;
font-size: .875rem;
}
.pending-option-item .opt-text { flex: 1; }
.pending-options-empty {
font-size: .8rem;
color: var(--text-secondary);
padding: 4px 0 6px;
font-style: italic;
}
/* Branch indicator on answer options */
.opt-branch {
font-size: .75rem;
font-weight: 600;
color: #7c3aed;
background: #ede9fe;
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
/* Config section inside question forms */
.config-section {
border-top: 1px solid var(--border);
margin-top: 12px;
padding-top: 12px;
}
.config-section .form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font);
resize: vertical;
}
/* Condition details toggle */
.condition-details summary {
list-style: none;
}
.condition-details summary::-webkit-details-marker {
display: none;
}
.condition-details summary::before {
content: '\25B6 ';
font-size: .7rem;
transition: transform .2s;
display: inline-block;
margin-right: 4px;
}
.condition-details[open] summary::before {
transform: rotate(90deg);
}
/* ── Language manager ── */
.lang-manager {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 20px;
}
.lang-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.lang-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 4px 10px 4px 12px;
font-size: .85rem;
}
.lang-chip strong { color: var(--primary); }
.lang-chip-name { color: var(--text-secondary); }
.lang-chip-remove {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.lang-chip-remove:hover { color: var(--danger); }
.lang-add-row {
display: flex;
gap: 8px;
align-items: center;
}
.lang-add-code {
width: 80px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .85rem;
}
.lang-add-name {
width: 160px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .85rem;
}
/* ── Translations toolbar ── */
.trans-toolbar {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 14px;
flex-wrap: wrap;
}
.trans-search-wrap { flex: 1; min-width: 200px; }
.trans-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .9rem;
font-family: var(--font);
}
.trans-search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
}
.trans-filter-types {
display: flex;
gap: 12px;
flex-shrink: 0;
}
/* ── Translations table ── */
.trans-table-wrapper {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.trans-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.trans-table th {
position: sticky;
top: 0;
background: #f1f5f9;
font-weight: 600;
text-align: left;
padding: 8px 10px;
border-bottom: 2px solid var(--border);
white-space: nowrap;
z-index: 1;
}
.trans-table td { padding: 2px 4px; }
.trans-table tbody tr { border-bottom: 1px solid #f1f5f9; }
.trans-table tbody tr:hover { background: #f8fafc; }
.trans-row-hidden { display: none; }
.col-key { min-width: 180px; }
.col-type { width: 50px; text-align: center; }
.col-lang { min-width: 160px; }
.cell-key {
font-family: monospace;
font-size: .82rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
padding: 6px 10px !important;
}
.cell-type { text-align: center; padding: 6px 4px !important; }
.trans-type-badge {
display: inline-block;
font-size: .7rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: .3px;
}
.badge-q { background: #dbeafe; color: #1e40af; }
.badge-opt { background: #fef3c7; color: #92400e; }
.badge-str { background: #ede9fe; color: #5b21b6; }
.cell-trans { position: relative; }
.cell-missing { background: #fff7ed; }
.trans-cell-input {
width: 100%;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 4px;
font-size: .85rem;
font-family: var(--font);
background: transparent;
transition: border-color .15s, background .15s;
}
.trans-cell-input:hover {
border-color: var(--border);
background: var(--surface);
}
.trans-cell-input:focus {
outline: none;
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 2px rgba(37,99,235,.1);
}
.trans-cell-input::placeholder {
color: #f59e0b;
font-style: italic;
font-size: .8rem;
}
.trans-cell-input.save-flash {
animation: saveFlash .4s ease;
}
@keyframes saveFlash {
0% { background: #dcfce7; }
100% { background: transparent; }
}
/* ── Stats bar ── */
.trans-stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
font-size: .82rem;
color: var(--text-secondary);
}
.trans-stats-progress {
display: flex;
align-items: center;
gap: 8px;
}
.trans-progress-bar {
width: 120px;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.trans-progress-fill {
display: block;
height: 100%;
background: var(--success);
border-radius: 3px;
transition: width .3s ease;
}

58
website/index.html Normal file
View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BW Sch&uuml;tzt - Dashboard</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="layout">
<nav class="sidebar" id="mainNav" style="display:none">
<div class="sidebar-brand">
BW Sch&uuml;tzt
<small>Questionnaire Management</small>
</div>
<ul class="sidebar-nav">
<li>
<a href="#/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Dashboard
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
Users
</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>
Assign Clients
</a>
</li>
<li>
<a href="#/export">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export Data
</a>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info" id="userInfo"></div>
<a href="#" id="logoutBtn" class="btn btn-sm" style="width:100%;justify-content:center;">Log out</a>
</div>
</nav>
<main class="main-content" id="app">
<!-- Pages render here -->
</main>
</div>
<div id="toastContainer"></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

93
website/js/api.js Normal file
View File

@ -0,0 +1,93 @@
const API_BASE = '../api';
function getToken() {
return localStorage.getItem('qdb_token');
}
async function apiFetch(endpoint, options = {}) {
const token = getToken();
const headers = options.headers || {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json; charset=UTF-8';
}
// Strip .php suffix for clean URLs
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers });
if (res.status === 401 || res.status === 403) {
const json = await res.json().catch(() => ({}));
const errMsg = json.error?.message || json.error || '';
if (res.status === 401 || errMsg === 'Invalid or expired token') {
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
window.location.hash = '#/login';
throw new Error('Session expired');
}
throw new Error(errMsg || 'Permission denied');
}
return res;
}
/**
* Unwrap the unified response envelope:
* {"ok": true, "data": ...} -> returns { success: true, ...data }
* {"ok": false, "error": ...} -> returns { success: false, error: message }
* Also supports legacy format for backward compat.
*/
function unwrap(json) {
if (typeof json.ok === 'boolean') {
if (json.ok) {
const d = json.data || {};
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
}
return { success: false, error: json.error?.message || 'Unknown error' };
}
return json;
}
export async function apiGet(endpoint) {
const res = await apiFetch(endpoint);
return unwrap(await res.json());
}
export async function apiPost(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'POST',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export async function apiPut(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'PUT',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export async function apiPatch(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'PATCH',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export async function apiDelete(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'DELETE',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export function apiDownloadUrl(endpoint) {
const token = getToken();
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
return `${API_BASE}/${cleanEndpoint}${cleanEndpoint.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
}

103
website/js/app.js Normal file
View File

@ -0,0 +1,103 @@
import { addRoute, startRouter, navigate } from './router.js';
import { loginPage } from './pages/login.js';
import { dashboardPage } from './pages/dashboard.js';
import { editorPage } from './pages/editor.js';
import { resultsPage } from './pages/results.js';
import { exportPage } from './pages/export.js';
import { usersPage } from './pages/users.js';
import { assignmentsPage } from './pages/assignments.js';
// Auth state
export function isLoggedIn() {
return !!localStorage.getItem('qdb_token');
}
export function getRole() {
return localStorage.getItem('qdb_role') || '';
}
export function getUser() {
return localStorage.getItem('qdb_user') || '';
}
export function canEdit() {
const r = getRole();
return r === 'admin' || r === 'supervisor';
}
export function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function authGuard(handler) {
return (params) => {
if (!isLoggedIn()) { navigate('#/login'); return; }
return handler(params);
};
}
function updateNav() {
const nav = document.getElementById('mainNav');
const userInfo = document.getElementById('userInfo');
if (isLoggedIn()) {
nav.style.display = '';
const role = getRole();
userInfo.textContent = `${getUser()} (${role})`;
// Show/hide role-gated nav items
document.querySelectorAll('[data-nav-roles]').forEach(li => {
const allowed = li.dataset.navRoles.split(' ');
li.style.display = allowed.includes(role) ? '' : 'none';
});
// Highlight active nav link
const hash = window.location.hash.slice(1) || '/';
document.querySelectorAll('.sidebar-nav a').forEach(a => {
const href = a.getAttribute('href').slice(1); // strip leading #
a.classList.toggle('active', hash === href || (href !== '/' && hash.startsWith(href)));
});
} else {
nav.style.display = 'none';
userInfo.textContent = '';
}
}
function roleGuard(roles, handler) {
return (params) => {
if (!isLoggedIn()) { navigate('#/login'); return; }
if (!roles.includes(getRole())) { navigate('#/'); return; }
return handler(params);
};
}
// Routes
addRoute('/login', loginPage);
addRoute('/', authGuard(dashboardPage));
addRoute('/dashboard', authGuard(dashboardPage));
addRoute('/questionnaire/new', authGuard(editorPage));
addRoute('/questionnaire/:id/results', authGuard(resultsPage));
addRoute('/questionnaire/:id', authGuard(editorPage));
addRoute('/export', authGuard(exportPage));
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
// Nav link handling & logout
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('logoutBtn').addEventListener('click', (e) => {
e.preventDefault();
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
navigate('#/login');
});
window.addEventListener('hashchange', updateNav);
updateNav();
startRouter();
});

View File

@ -0,0 +1,260 @@
import { apiGet, apiPost } from '../api.js';
import { showToast } from '../app.js';
let coaches = [];
let clients = [];
let selectedCoachID = null;
export async function assignmentsPage() {
selectedCoachID = null;
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Assign Clients</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div id="assignContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('assignments.php');
coaches = data.coaches || [];
clients = data.clients || [];
renderAssignments();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('assignContent').innerHTML =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function renderAssignments() {
const container = document.getElementById('assignContent');
if (!coaches.length) {
container.innerHTML = `
<div class="card empty-state">
<h3>No coaches found</h3>
<p>Create coaches first before assigning clients.</p>
</div>`;
return;
}
// Group clients by coachID for the filter badge counts
const clientsByCoach = {};
const unassigned = [];
clients.forEach(c => {
if (c.coachID) {
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
} else {
unassigned.push(c);
}
});
container.innerHTML = `
<div class="assign-layout">
<!-- Left: Coach list -->
<div class="assign-coaches card">
<h3 style="margin-bottom:12px">Coaches</h3>
<ul class="coach-list" id="coachList">
${coaches.map(c => {
const count = (clientsByCoach[c.coachID] || []).length;
return `
<li class="coach-list-item" data-id="${c.coachID}">
<div class="coach-name">${esc(c.username)}</div>
${c.supervisorUsername
? `<div class="coach-supervisor">${esc(c.supervisorUsername)}</div>`
: ''}
<span class="coach-client-count">${count} client${count === 1 ? '' : 's'}</span>
</li>`;
}).join('')}
</ul>
</div>
<!-- Right: Client list + assign panel -->
<div class="assign-right">
<div class="card assign-clients-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h3>Clients</h3>
<div class="filter-bar" style="margin:0">
<select id="filterByCoach">
<option value="">All coaches</option>
<option value="__unassigned__">Unassigned</option>
${coaches.map(c =>
`<option value="${c.coachID}">${esc(c.username)}</option>`
).join('')}
</select>
<input type="text" id="clientSearch" placeholder="Search client code..." style="width:160px">
</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-sm" id="selectAllBtn">Select all</button>
<button class="btn btn-sm" id="clearSelBtn">Clear</button>
</div>
</div>
<div class="table-wrapper" style="max-height:420px;overflow-y:auto">
<table class="data-table">
<thead>
<tr>
<th style="width:36px"><input type="checkbox" id="selectAllCb" title="Toggle all visible"></th>
<th>Client Code</th>
<th>Current Coach</th>
</tr>
</thead>
<tbody id="clientTableBody"></tbody>
</table>
</div>
<div id="selectionInfo" class="selection-info" style="display:none"></div>
</div>
<!-- Assign action card -->
<div class="card assign-action-card" id="assignActionCard" style="display:none">
<h4>Assign selected clients to:</h4>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px">
<select id="targetCoachSelect" style="flex:1;min-width:180px;padding:8px 12px;border:1px solid var(--border);border-radius:6px">
<option value="">— select coach —</option>
${coaches.map(c =>
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
).join('')}
</select>
<button class="btn btn-primary" id="assignBtn">Assign</button>
</div>
</div>
</div>
</div>
`;
// Wire coach list click → filter clients
document.querySelectorAll('.coach-list-item').forEach(li => {
li.addEventListener('click', () => {
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
li.classList.add('active');
selectedCoachID = li.dataset.id;
document.getElementById('filterByCoach').value = selectedCoachID;
renderClientRows();
});
});
// Wire filters
document.getElementById('filterByCoach').addEventListener('change', renderClientRows);
document.getElementById('clientSearch').addEventListener('input', renderClientRows);
// Wire select all / clear
document.getElementById('selectAllBtn').addEventListener('click', () => {
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = true);
updateSelectionInfo();
});
document.getElementById('clearSelBtn').addEventListener('click', () => {
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = false);
updateSelectionInfo();
});
document.getElementById('selectAllCb').addEventListener('change', (e) => {
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = e.target.checked);
updateSelectionInfo();
});
// Wire assign button
document.getElementById('assignBtn').addEventListener('click', doAssign);
renderClientRows();
}
function renderClientRows() {
const body = document.getElementById('clientTableBody');
const coachFilter = document.getElementById('filterByCoach').value;
const search = document.getElementById('clientSearch').value.trim().toLowerCase();
let visible = clients;
if (coachFilter === '__unassigned__') {
visible = clients.filter(c => !c.coachID);
} else if (coachFilter) {
visible = clients.filter(c => c.coachID === coachFilter);
}
if (search) {
visible = visible.filter(c => c.clientCode.toLowerCase().includes(search));
}
if (!visible.length) {
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match the current filter</td></tr>`;
updateSelectionInfo();
return;
}
body.innerHTML = visible.map(c => `
<tr>
<td><input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"></td>
<td><strong>${esc(c.clientCode)}</strong></td>
<td>${c.coachUsername ? esc(c.coachUsername) : '<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>'}</td>
</tr>
`).join('');
body.querySelectorAll('.client-cb').forEach(cb => {
cb.addEventListener('change', updateSelectionInfo);
});
updateSelectionInfo();
}
function getSelectedCodes() {
return [...document.querySelectorAll('#clientTableBody .client-cb:checked')]
.map(cb => cb.value);
}
function updateSelectionInfo() {
const selected = getSelectedCodes();
const info = document.getElementById('selectionInfo');
const card = document.getElementById('assignActionCard');
if (!selected.length) {
info.style.display = 'none';
card.style.display = 'none';
return;
}
info.style.display = '';
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected`;
card.style.display = '';
}
async function doAssign() {
const selected = getSelectedCodes();
const coachID = document.getElementById('targetCoachSelect').value;
if (!selected.length) { showToast('Select at least one client', 'error'); return; }
if (!coachID) { showToast('Select a target coach', 'error'); return; }
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return;
const btn = document.getElementById('assignBtn');
btn.disabled = true;
btn.textContent = 'Assigning...';
try {
const data = await apiPost('assignments.php', { clientCodes: selected, coachID });
if (data.success) {
// Update local data
selected.forEach(code => {
const c = clients.find(x => x.clientCode === code);
if (c) {
c.coachID = coachID;
c.coachUsername = coachName;
}
});
showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success');
renderAssignments();
}
} catch (e) {
showToast(e.message, 'error');
btn.disabled = false;
btn.textContent = 'Assign';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

View File

@ -0,0 +1,150 @@
import { apiGet, apiPut, apiDelete } from '../api.js';
import { canEdit, showToast, getRole } from '../app.js';
import { navigate } from '../router.js';
export async function dashboardPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Questionnaires</h1>
<div class="actions" id="headerActions"></div>
</div>
<div id="qGrid" class="q-grid"><div class="spinner"></div></div>
`;
if (canEdit()) {
document.getElementById('headerActions').innerHTML = `
<a href="#/questionnaire/new" class="btn btn-primary">+ New Questionnaire</a>
`;
}
try {
const data = await apiGet('questionnaires.php');
renderGrid(data.questionnaires || []);
} catch (e) {
showToast(e.message, 'error');
document.getElementById('qGrid').innerHTML = `<p class="error-text">${e.message}</p>`;
}
}
function renderGrid(questionnaires) {
const grid = document.getElementById('qGrid');
if (!questionnaires.length) {
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
<h3>No questionnaires yet</h3>
<p>Create your first questionnaire to get started.</p>
</div>
`;
return;
}
grid.innerHTML = questionnaires.map(q => {
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
const showPts = parseInt(q.showPoints || 0, 10);
return `
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
<div class="q-card-header">
<div class="q-card-title">
${canEdit() ? '<span class="drag-handle" title="Drag to reorder" style="margin-right:6px;cursor:grab;color:var(--text-secondary)">&#x2630;</span>' : ''}
${esc(q.name)}
</div>
<span class="badge ${stateClass}">${esc(q.state || 'draft')}</span>
</div>
<div class="q-card-meta">
<span>v${esc(q.version || '—')}</span>
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
<span>#${q.orderIndex ?? '—'}</span>
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
</div>
<div class="q-card-actions">
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">Results</a>
${getRole() === 'admin' ? `<button class="btn btn-sm btn-danger delete-q-btn" data-id="${q.questionnaireID}">Delete</button>` : ''}
</div>
</div>
`;
}).join('');
grid.querySelectorAll('.q-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.q-card-actions') || e.target.closest('.drag-handle')) return;
navigate(`#/questionnaire/${card.dataset.id}`);
});
});
grid.querySelectorAll('.delete-q-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('Delete this questionnaire and all its data?')) return;
try {
await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id });
showToast('Questionnaire deleted', 'success');
btn.closest('.q-card').remove();
} catch (err) {
showToast(err.message, 'error');
}
});
});
if (canEdit()) initGridDrag(questionnaires);
}
function initGridDrag(questionnaires) {
const grid = document.getElementById('qGrid');
if (!grid) return;
let dragItem = null;
grid.addEventListener('dragstart', (e) => {
const card = e.target.closest('.q-card');
if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
dragItem = card;
card.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
grid.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragItem) return;
const cards = [...grid.querySelectorAll('.q-card:not([style*="opacity"])')];
const afterCard = cards.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = e.clientY - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) return { offset, element: child };
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
if (afterCard) grid.insertBefore(dragItem, afterCard);
else grid.appendChild(dragItem);
});
grid.addEventListener('dragend', async () => {
if (!dragItem) return;
dragItem.style.opacity = '';
const newOrder = [...grid.querySelectorAll('.q-card')].map(el => el.dataset.id);
dragItem = null;
for (let i = 0; i < newOrder.length; i++) {
const id = newOrder[i];
const q = questionnaires.find(q => q.questionnaireID === id);
if (q && parseInt(q.orderIndex, 10) !== i) {
try {
await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i });
q.orderIndex = i;
} catch (e) {
showToast(e.message, 'error');
return;
}
}
}
showToast('Order saved', 'success');
});
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

1272
website/js/pages/editor.js Normal file

File diff suppressed because it is too large Load Diff

104
website/js/pages/export.js Normal file
View File

@ -0,0 +1,104 @@
import { apiGet } from '../api.js';
import { showToast } from '../app.js';
export async function exportPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Export Data</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div class="card" id="exportContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('questionnaires.php');
renderExport(data.questionnaires || []);
} catch (e) {
showToast(e.message, 'error');
document.getElementById('exportContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderExport(questionnaires) {
const container = document.getElementById('exportContent');
if (!questionnaires.length) {
container.innerHTML = `
<div class="empty-state">
<h3>No questionnaires</h3>
<p>Create questionnaires first to export data.</p>
</div>
`;
return;
}
container.innerHTML = `
<p style="margin-bottom:16px;color:var(--text-secondary)">
Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).
</p>
<table class="data-table">
<thead>
<tr>
<th>Questionnaire</th>
<th>Version</th>
<th>State</th>
<th>Questions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${questionnaires.map(q => `
<tr>
<td><strong>${esc(q.name)}</strong></td>
<td>${esc(q.version || '—')}</td>
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
<td>${q.questionCount || 0}</td>
<td>
<button class="btn btn-sm btn-primary export-btn" data-id="${q.questionnaireID}" data-name="${esc(q.name)}" data-version="${esc(q.version)}">
Export CSV
</button>
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">View Results</a>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.querySelectorAll('.export-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Downloading...';
try {
const token = localStorage.getItem('qdb_token');
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Download failed' }));
throw new Error(err.error);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('Download started', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Export CSV';
}
});
});
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

124
website/js/pages/login.js Normal file
View File

@ -0,0 +1,124 @@
import { navigate } from '../router.js';
import { isLoggedIn, showToast } from '../app.js';
export function loginPage() {
if (isLoggedIn()) { navigate('#/'); return; }
const app = document.getElementById('app');
app.innerHTML = `
<div class="login-wrapper">
<div class="login-card card">
<h1>BW Sch&uuml;tzt</h1>
<p class="subtitle">Questionnaire Management</p>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Log in</button>
<p id="loginError" class="error-text" style="display:none"></p>
</form>
<div id="changePwSection" style="display:none">
<hr>
<p>You must change your password before continuing.</p>
<form id="changePwForm">
<div class="form-group">
<label for="newPw">New password</label>
<input type="password" id="newPw" autocomplete="new-password" required minlength="6">
</div>
<div class="form-group">
<label for="newPwConfirm">Confirm password</label>
<input type="password" id="newPwConfirm" autocomplete="new-password" required minlength="6">
</div>
<button type="submit" class="btn btn-primary btn-block">Change &amp; continue</button>
<p id="changePwError" class="error-text" style="display:none"></p>
</form>
</div>
</div>
</div>
`;
let pendingUsername = '';
let pendingTempToken = '';
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('loginError');
errEl.style.display = 'none';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('../api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const json = await res.json();
if (!json.ok) {
errEl.textContent = json.error?.message || 'Login failed';
errEl.style.display = '';
return;
}
const d = json.data;
if (d.mustChangePassword) {
pendingUsername = username;
pendingTempToken = d.token;
document.getElementById('loginForm').style.display = 'none';
document.getElementById('changePwSection').style.display = '';
return;
}
localStorage.setItem('qdb_token', d.token);
localStorage.setItem('qdb_user', d.user);
localStorage.setItem('qdb_role', d.role);
navigate('#/');
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = '';
}
});
document.getElementById('changePwForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('changePwError');
errEl.style.display = 'none';
const newPw = document.getElementById('newPw').value;
const confirm = document.getElementById('newPwConfirm').value;
if (newPw !== confirm) {
errEl.textContent = 'Passwords do not match';
errEl.style.display = '';
return;
}
const oldPw = document.getElementById('password').value;
try {
const headers = { 'Content-Type': 'application/json' };
if (pendingTempToken) {
headers['Authorization'] = `Bearer ${pendingTempToken}`;
}
const res = await fetch('../api/auth/change-password', {
method: 'POST',
headers,
body: JSON.stringify({ username: pendingUsername, old_password: oldPw, new_password: newPw }),
});
const json = await res.json();
if (!json.ok) {
errEl.textContent = json.error?.message || 'Password change failed';
errEl.style.display = '';
return;
}
const d = json.data;
localStorage.setItem('qdb_token', d.token);
localStorage.setItem('qdb_user', d.user);
localStorage.setItem('qdb_role', d.role);
showToast('Password changed successfully', 'success');
navigate('#/');
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = '';
}
});
}

264
website/js/pages/results.js Normal file
View File

@ -0,0 +1,264 @@
import { apiGet } from '../api.js';
import { showToast } from '../app.js';
let sortCol = null;
let sortDir = 'asc';
let allClients = [];
let questionsDef = [];
let questionnaireMeta = null;
let filterCoach = '';
let filterStatus = '';
export async function resultsPage(params) {
const app = document.getElementById('app');
sortCol = null;
sortDir = 'asc';
filterCoach = '';
filterStatus = '';
app.innerHTML = `
<div class="page-header">
<h1 id="resultsTitle">Results</h1>
<div class="actions">
<a href="#/questionnaire/${params.id}" class="btn">&larr; Back to Editor</a>
<a href="#/" class="btn">Dashboard</a>
</div>
</div>
<div id="resultsContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`);
questionnaireMeta = data.questionnaire;
questionsDef = data.questions || [];
allClients = data.clients || [];
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
renderResults();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('resultsContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderResults() {
const container = document.getElementById('resultsContent');
// Collect unique coaches and statuses for filters
const coaches = [...new Set(allClients.map(c => c.coachID))].sort();
const statuses = [...new Set(allClients.map(c => c.status))].filter(Boolean).sort();
container.innerHTML = `
<div class="card" style="margin-bottom:16px">
<div class="filter-bar">
<label style="font-size:.85rem;font-weight:500">Filter:</label>
<select id="filterCoach">
<option value="">All coaches</option>
${coaches.map(c => `<option value="${esc(c)}" ${filterCoach === c ? 'selected' : ''}>${esc(c)}</option>`).join('')}
</select>
<select id="filterStatus">
<option value="">All statuses</option>
${statuses.map(s => `<option value="${esc(s)}" ${filterStatus === s ? 'selected' : ''}>${esc(s)}</option>`).join('')}
</select>
<span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span>
<a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a>
</div>
</div>
<div class="card">
<div class="table-wrapper">
<table class="data-table" id="resultsTable">
<thead><tr id="resultsHead"></tr></thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('filterCoach').addEventListener('change', (e) => {
filterCoach = e.target.value;
renderTableRows();
});
document.getElementById('filterStatus').addEventListener('change', (e) => {
filterStatus = e.target.value;
renderTableRows();
});
document.getElementById('exportCsvLink').addEventListener('click', (e) => {
e.preventDefault();
exportCSV();
});
renderTableHead();
renderTableRows();
}
function renderTableHead() {
const head = document.getElementById('resultsHead');
const fixedCols = [
{ key: 'clientCode', label: 'Client' },
{ key: 'coachID', label: 'Coach' },
{ key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' },
];
const questionCols = questionsDef.map(q => ({
key: `q_${q.questionID}`,
label: q.defaultText,
questionID: q.questionID,
}));
const allCols = [...fixedCols, ...questionCols];
head.innerHTML = allCols.map(col => {
let cls = 'sortable';
if (sortCol === col.key) cls += sortDir === 'asc' ? ' sort-asc' : ' sort-desc';
return `<th class="${cls}" data-key="${col.key}" title="${esc(col.label)}">${esc(col.label)}</th>`;
}).join('');
head.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.key;
if (sortCol === key) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else { sortCol = key; sortDir = 'asc'; }
renderTableHead();
renderTableRows();
});
});
}
function getFilteredClients() {
let clients = allClients;
if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach);
if (filterStatus) clients = clients.filter(c => c.status === filterStatus);
return clients;
}
function renderTableRows() {
const body = document.getElementById('resultsBody');
let clients = getFilteredClients();
// Sort
if (sortCol) {
clients = [...clients].sort((a, b) => {
let va = getCellValue(a, sortCol);
let vb = getCellValue(b, sortCol);
if (va == null) va = '';
if (vb == null) vb = '';
if (typeof va === 'number' && typeof vb === 'number') {
return sortDir === 'asc' ? va - vb : vb - va;
}
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
}
document.getElementById('resultCount').textContent = `${clients.length} client${clients.length === 1 ? '' : 's'}`;
if (!clients.length) {
body.innerHTML = `<tr><td colspan="99" style="text-align:center;color:var(--text-secondary);padding:30px">No results found</td></tr>`;
return;
}
// Build option text lookup
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
body.innerHTML = clients.map(c => {
const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—';
const statusBadge = c.status ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—';
const questionCells = questionsDef.map(q => {
const ans = c.answers && c.answers[q.questionID];
if (!ans) return '<td>—</td>';
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
}
if (ans.freeTextValue) return `<td>${esc(ans.freeTextValue)}</td>`;
if (ans.numericValue !== null && ans.numericValue !== undefined) return `<td>${ans.numericValue}</td>`;
return '<td>—</td>';
}).join('');
return `<tr>
<td>${esc(c.clientCode)}</td>
<td>${esc(c.coachID)}</td>
<td>${statusBadge}</td>
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
<td>${completedDate}</td>
${questionCells}
</tr>`;
}).join('');
}
function getCellValue(client, key) {
if (key === 'clientCode') return client.clientCode;
if (key === 'coachID') return client.coachID;
if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints;
if (key === 'completedAt') return client.completedAt;
if (key.startsWith('q_')) {
const qid = key.substring(2);
const ans = client.answers && client.answers[qid];
if (!ans) return null;
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
if (ans.freeTextValue) return ans.freeTextValue;
return ans.answerOptionID || null;
}
return null;
}
function exportCSV() {
const clients = getFilteredClients();
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
const headers = ['clientCode', 'coachID', 'status', 'sumPoints', 'completedAt',
...questionsDef.map(q => q.defaultText)];
const rows = clients.map(c => {
const base = [
c.clientCode, c.coachID, c.status, c.sumPoints ?? '',
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
];
const answerCols = questionsDef.map(q => {
const ans = c.answers && c.answers[q.questionID];
if (!ans) return '';
if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID];
if (ans.freeTextValue) return ans.freeTextValue;
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
return '';
});
return [...base, ...answerCols];
});
let csv = '\uFEFF'; // BOM
csv += headers.map(csvEsc).join(',') + '\n';
rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${questionnaireMeta.name}_v${questionnaireMeta.version}_results.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('CSV exported', 'success');
}
function csvEsc(val) {
const s = String(val ?? '');
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

326
website/js/pages/users.js Normal file
View File

@ -0,0 +1,326 @@
import { apiGet, apiPost, apiDelete } from '../api.js';
import { getRole, getUser, showToast } from '../app.js';
// Roles an admin can create; supervisors can only create coaches
const CREATEABLE_ROLES = {
admin: ['admin', 'supervisor', 'coach'],
supervisor: ['coach'],
};
let usersList = [];
let supervisorsList = [];
let callerRole = '';
export async function usersPage() {
callerRole = getRole();
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Users</h1>
<div class="actions">
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
</div>
</div>
<div id="createUserWrapper" style="display:none"></div>
<div id="usersContent"><div class="spinner"></div></div>
`;
document.getElementById('showCreateFormBtn').addEventListener('click', () => {
const wrapper = document.getElementById('createUserWrapper');
if (wrapper.style.display === 'none') {
showCreateForm();
} else {
hideCreateForm();
}
});
await loadUsers();
}
async function loadUsers() {
try {
const data = await apiGet('users.php');
usersList = data.users || [];
supervisorsList = data.supervisors || [];
callerRole = data.callerRole || callerRole;
renderUsers();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('usersContent').innerHTML =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
// ── User table ────────────────────────────────────────────────────────────
function renderUsers() {
const container = document.getElementById('usersContent');
const myUsername = getUser();
if (!usersList.length) {
container.innerHTML = `
<div class="card">
<div class="empty-state">
<h3>No users found</h3>
<p>${callerRole === 'supervisor' ? 'You have no coaches yet.' : 'Create the first user above.'}</p>
</div>
</div>`;
return;
}
// Group by role for a cleaner display
const byRole = { admin: [], supervisor: [], coach: [] };
usersList.forEach(u => (byRole[u.role] || (byRole.other = byRole.other || [])).push(u));
// Which role groups to show
const groups = callerRole === 'supervisor'
? [{ label: 'Coaches', key: 'coach' }]
: [
{ label: 'Admins', key: 'admin' },
{ label: 'Supervisors', key: 'supervisor' },
{ label: 'Coaches', key: 'coach' },
];
container.innerHTML = groups.map(group => {
const users = byRole[group.key] || [];
if (!users.length) return '';
return `
<div class="card" style="margin-bottom:16px">
<h3 style="margin-bottom:12px">${group.label} (${users.length})</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Username</th>
${group.key !== 'coach' ? '<th>Location</th>' : '<th>Supervisor</th>'}
<th>Password Change Required</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(u => userRowHTML(u, myUsername)).join('')}
</tbody>
</table>
</div>
</div>`;
}).join('');
container.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
});
}
function userRowHTML(u, myUsername) {
const isSelf = u.username === myUsername;
const detail = u.role === 'coach'
? esc(u.supervisorUsername || '—')
: esc(u.location || '—');
const created = u.createdAt
? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString()
: '—';
const pwBadge = u.mustChangePassword == 1
? '<span class="badge badge-pending">Pending</span>'
: '<span style="color:var(--text-secondary);font-size:.8rem">No</span>';
const canDelete = !isSelf && (
callerRole === 'admin' ||
(callerRole === 'supervisor' && u.role === 'coach')
);
return `
<tr>
<td>
<strong>${esc(u.username)}</strong>
${isSelf ? '<span class="badge badge-you">You</span>' : ''}
</td>
<td>${detail}</td>
<td>${pwBadge}</td>
<td>${created}</td>
<td>
${canDelete
? `<button class="btn btn-sm btn-danger delete-user-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Delete</button>`
: ''}
</td>
</tr>`;
}
async function deleteUser(userID, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {
const data = await apiDelete('users.php', { userID });
if (data.success) {
usersList = usersList.filter(u => u.userID !== userID);
showToast(`User "${username}" deleted`, 'success');
renderUsers();
}
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Create user form ──────────────────────────────────────────────────────
function showCreateForm() {
const wrapper = document.getElementById('createUserWrapper');
const allowedRoles = CREATEABLE_ROLES[callerRole] || ['coach'];
const isSupervisor = callerRole === 'supervisor';
wrapper.style.display = '';
wrapper.innerHTML = `
<div class="inline-form-card" style="margin-bottom:20px">
<h4>Create New User</h4>
<div class="form-row">
<div class="form-group" style="flex:2">
<label>Username</label>
<input type="text" id="cu_username" autocomplete="off" placeholder="Enter username">
</div>
<div class="form-group" style="flex:2">
<label>Password</label>
<input type="password" id="cu_password" autocomplete="new-password" placeholder="Min 6 characters">
</div>
<div class="form-group" style="flex:1;min-width:160px">
<label>Role</label>
${isSupervisor
? `<input type="text" value="Coach" disabled style="background:#f8fafc">`
: `<select id="cu_role">
${allowedRoles.map(r =>
`<option value="${r}">${r.charAt(0).toUpperCase() + r.slice(1)}</option>`
).join('')}
</select>`
}
</div>
</div>
<div id="cu_location_row" class="form-group" ${isSupervisor ? 'style="display:none"' : ''}>
<label>Location <span style="font-weight:400;color:var(--text-secondary)">(for admin/supervisor)</span></label>
<input type="text" id="cu_location" placeholder="e.g. Stuttgart">
</div>
<div id="cu_supervisor_row" class="form-group" style="${isSupervisor ? '' : 'display:none'}">
<label>Supervisor</label>
${isSupervisor
? `<input type="text" value="You (auto-assigned)" disabled style="background:#f8fafc">`
: `<select id="cu_supervisor_select">
<option value="">— select supervisor —</option>
${supervisorsList.map(s =>
`<option value="${s.supervisorID}">${esc(s.username)}${s.location ? ` (${esc(s.location)})` : ''}</option>`
).join('')}
</select>`
}
</div>
<label class="checkbox-label" style="margin-bottom:14px;display:inline-flex">
<input type="checkbox" id="cu_mustchange" checked> Require password change on first login
</label>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="cu_submit">Create User</button>
<button class="btn btn-sm" id="cu_cancel">Cancel</button>
</div>
<p id="cu_error" class="error-text" style="display:none;margin-top:8px"></p>
</div>
`;
document.getElementById('cu_username').focus();
// Show/hide location vs supervisor field based on role selection (admin only)
if (!isSupervisor) {
document.getElementById('cu_role').addEventListener('change', (e) => {
const r = e.target.value;
document.getElementById('cu_location_row').style.display = r !== 'coach' ? '' : 'none';
document.getElementById('cu_supervisor_row').style.display = r === 'coach' ? '' : 'none';
});
// Set initial state based on default selection
const initRole = document.getElementById('cu_role').value;
document.getElementById('cu_location_row').style.display = initRole !== 'coach' ? '' : 'none';
document.getElementById('cu_supervisor_row').style.display = initRole === 'coach' ? '' : 'none';
}
document.getElementById('cu_submit').addEventListener('click', submitCreateUser);
document.getElementById('cu_cancel').addEventListener('click', hideCreateForm);
// Enter key on last visible field submits
wrapper.querySelectorAll('input').forEach(inp => {
inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitCreateUser();
if (e.key === 'Escape') hideCreateForm();
});
});
}
function hideCreateForm() {
const wrapper = document.getElementById('createUserWrapper');
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
}
async function submitCreateUser() {
const errEl = document.getElementById('cu_error');
errEl.style.display = 'none';
const isSupervisor = callerRole === 'supervisor';
const username = document.getElementById('cu_username').value.trim();
const password = document.getElementById('cu_password').value;
const role = isSupervisor ? 'coach' : (document.getElementById('cu_role').value || 'coach');
const mustChange = document.getElementById('cu_mustchange').checked ? 1 : 0;
let location = '';
let supervisorID = '';
if (!isSupervisor) {
if (role !== 'coach') {
location = document.getElementById('cu_location').value.trim();
} else {
supervisorID = document.getElementById('cu_supervisor_select').value;
}
}
if (!username) { showError(errEl, 'Username is required'); return; }
if (password.length < 6) { showError(errEl, 'Password must be at least 6 characters'); return; }
if (role === 'coach' && !isSupervisor && !supervisorID) {
showError(errEl, 'Please select a supervisor for this coach');
return;
}
const btn = document.getElementById('cu_submit');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const data = await apiPost('users.php', {
username, password, role, location, supervisorID, mustChangePassword: mustChange,
});
if (!data.success) throw new Error(data.error || 'Failed to create user');
// Add to local list and re-render
usersList.push({
userID: data.userID,
username: data.username,
role: data.role,
entityID: data.entityID,
location: data.location || '',
supervisorID: data.supervisorID || null,
supervisorUsername: data.supervisorUsername || null,
mustChangePassword: data.mustChangePassword,
createdAt: data.createdAt,
});
showToast(`User "${data.username}" created`, 'success');
hideCreateForm();
renderUsers();
} catch (e) {
showError(errEl, e.message);
btn.disabled = false;
btn.textContent = 'Create User';
}
}
function showError(el, msg) {
el.textContent = msg;
el.style.display = '';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

56
website/js/router.js Normal file
View File

@ -0,0 +1,56 @@
const routes = [];
let currentCleanup = null;
export function addRoute(pattern, handler) {
let regex;
const paramNames = [];
if (pattern instanceof RegExp) {
regex = pattern;
} else {
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return '/([^/]+)';
});
regex = new RegExp('^' + parts + '$');
}
routes.push({ regex, paramNames, handler });
}
export function navigate(hash) {
window.location.hash = hash;
}
export function currentHash() {
return window.location.hash.slice(1) || '/';
}
async function resolve() {
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
const path = currentHash();
for (const route of routes) {
const match = path.match(route.regex);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(match[i + 1]);
});
try {
currentCleanup = await route.handler(params) || null;
} catch (e) {
console.error('Route handler error:', e);
}
return;
}
}
// fallback: dashboard
navigate('#/');
}
export function startRouter() {
window.addEventListener('hashchange', resolve);
resolve();
}

View File

@ -0,0 +1,136 @@
{
"meta": {
"_comment": "Diese Zeiel erklärt im folgenden wie die einzelnen Fragetypen definiert werden können und was für features diese mit sich bringen.",
"id": "questionnaire_0_readme"
},
"questions": [
{
"id": "q1",
"_comment": "Frage mit Radio Buttons. In diesem Fragetypen kann man maximal eine Antwort wählen. Über nextQuestionId kann man die nächste Frage definieren., Über pointsMap kann man die Punkte für die Antwort der Frage festlegen.",
"layout": "radio_question",
"question": "consent_instruction",
"options": [
{ "key": "consent_signed"},
{ "key": "consent_not_signed",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q2",
"_comment": "Frage nach dem Code des Coaches.",
"layout": "client_not_signed",
"textKey1": "no_consent_entered",
"textKey2": "no_consent_note",
"question": "coach_code_request",
"hint": "coach_code"
},
{
"id": "q3",
"_comment": "Frage mit Spinner, dessen Antwortmöglichkeiten über options selbst definiert werden können.",
"layout": "string_spinner",
"question": "other_accommodation",
"options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"]
},
{
"id": "q4",
"_comment": "Frage mit Spinner zur Auswahl des Datums",
"layout": "date_spinner",
"question": "departure_country"
},
{
"id": "q5",
"_comment": "Frage mit Spinner zur Auswahl des Datums. Über constraints und notBefore und notAfter, ist es möglich das Datum nicht in der Zukunft oder Vergangenheit zu wählen.",
"layout": "date_spinner",
"question": "since_in_germany",
"constraints": {
"notBefore": "departure_country",
"notAfter": "departure_country"
}
},
{
"id": "q6",
"_comment": "Frage mit Spinner, dessen Antwortmöglichkeiten über range selbst definiert werden können.",
"layout": "value_spinner",
"question": "number_family_members",
"range": {
"min": 1,
"max": 15
}
},
{
"id": "q7",
"_comment": "Frage nach dem Code des Klienten und den Code des Coaches.",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q8",
"_comment": "Frage mit Checkboxen, dessen Antwortmöglichkeiten über options selbst definiert werden können. Über pointsMap können die Punkte für die jeweiligen Antworten festgelegt werden. Über minSelection kann die minimal Anzahl an auszufüllenden Antworten festgelegt werden.",
"layout": "multi_check_box_question",
"question": "languages_spoken",
"options": [
{ "key": "language_albanian" },
{ "key": "language_pashto" },
{ "key": "language_russian" },
{ "key": "language_other"}
],
"pointsMap": {
"language_albanian": 1,
"language_pashto": 1,
"language_russian": 1,
"language_other": 1
},
"minSelection": 1
},
{
"id": "q9",
"_comment":"Glass scale frage, bei der es für jede Frage, welche über symptoms festgelegt werden kann eine Antwort festgelegt werden muss. Die Antwort ganz links gibt immer 0 Punkte und die Antwort ganz rechts gibt immer 4 Punkte.",
"layout": "glass_scale_question",
"question": "glass_explanation",
"symptoms": [
"q1_symptom",
"q2_symptom",
"q3_symptom",
"q4_symptom",
"q5_symptom",
"q6_symptom",
"q7_symptom",
"q8_symptom",
"q9_symptom"
]
},
{
"id": "last_page",
"_comment":"Letzte Seite, welche den Abschluss des Fragebogens markiert.",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,302 @@
{
"meta": {
"id": "questionnaire_1_demographic_information"
},
"questions": [
{
"id": "q1",
"layout": "radio_question",
"question": "consent_instruction",
"options": [
{ "key": "consent_signed",
"nextQuestionId": "q6"},
{ "key": "consent_not_signed",
"nextQuestionId": "q2"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q2",
"layout": "client_coach_code_question",
"question": "no_consent_entered",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
},
{
"id": "q28",
"layout": "radio_question",
"question": "accommodation",
"options": [
{ "key": "steinstrasse",
"nextQuestionId": "last_page"},
{ "key": "doerfle",
"nextQuestionId": "last_page"},
{ "key": "zoll_emmishofer",
"nextQuestionId": "last_page"},
{ "key": "other",
"nextQuestionId": "q4"}
]
},
{
"id": "q4",
"layout": "string_spinner",
"question": "other_accommodation",
"options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"]
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
},
{
"id": "q6",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q29",
"layout": "radio_question",
"question": "accommodation",
"options": [
{ "key": "steinstrasse",
"nextQuestionId": "q9"},
{ "key": "doerfle",
"nextQuestionId": "q9"},
{ "key": "zoll_emmishofer",
"nextQuestionId": "q9"},
{ "key": "other",
"nextQuestionId": "q8"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q8",
"layout": "string_spinner",
"question": "other_accommodation",
"options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"]
},
{
"id": "q9",
"layout": "value_spinner",
"question": "age",
"range": {
"min": 18,
"max": 122
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "gender",
"options": [
{ "key": "gender_male"},
{ "key": "gender_female"},
{ "key": "gender_diverse"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q11",
"layout": "string_spinner",
"question": "country_of_origin"
},
{
"id": "q13",
"layout": "date_spinner",
"question": "departure_country"
},
{
"id": "q14",
"layout": "date_spinner",
"question": "since_in_germany",
"constraints": {
"notBefore": "departure_country"
}
},
{
"id": "q16",
"layout": "radio_question",
"question": "living_situation",
"options": [
{ "key": "alone",
"nextQuestionId": "q18"},
{ "key": "with_family",
"nextQuestionId": "q17"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q17",
"layout": "value_spinner",
"question": "number_family_members",
"range": {
"min": 1,
"max": 15
}
},
{
"id": "q18",
"layout": "multi_check_box_question",
"question": "languages_spoken",
"options": [
{ "key": "language_albanian" },
{ "key": "language_pashto" },
{ "key": "language_russian" },
{ "key": "language_serbo" },
{ "key": "language_somali" },
{ "key": "language_tamil" },
{ "key": "language_tigrinya" },
{ "key": "language_turkish" },
{ "key": "language_ukrainian" },
{ "key": "language_urdu" },
{ "key": "language_arabic" },
{ "key": "language_dari_farsi" },
{ "key": "language_chinese" },
{ "key": "language_english" },
{ "key": "language_macedonian" },
{ "key": "language_kurmanji" },
{ "key": "language_hindi" },
{ "key": "language_french"},
{ "key": "language_other"}
],
"pointsMap": {
"language_albanian": 0,
"language_pashto": 0,
"language_russian": 0,
"language_serbo": 0,
"language_somali": 0,
"language_tamil": 0,
"language_tigrinya": 0,
"language_turkish": 0,
"language_ukrainian": 0,
"language_urdu": 0,
"language_arabic": 0,
"language_dari_farsi": 0,
"language_chinese": 0,
"language_english": 0,
"language_macedonian": 0,
"language_kurmanji": 0,
"language_hindi": 0,
"language_french": 0,
"language_other": 0
},
"minSelection": 1
},
{
"id": "q19",
"layout": "radio_question",
"question": "german_skills",
"options": [
{ "key": "skill_none"},
{ "key": "skill_basic"},
{ "key": "skill_a1"},
{ "key": "skill_a2"},
{ "key": "skill_b1"},
{ "key": "skill_b2"},
{ "key": "skill_c1"},
{ "key": "skill_c2"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q30",
"layout": "value_spinner",
"question": "school_years_total",
"range": {
"min": 0,
"max": 15
},
"options": [
{ "value": 0, "nextQuestionId": "q23" }
]
},
{
"id": "q20",
"layout": "value_spinner",
"question": "school_years_origin",
"range": {
"min": 0,
"max": 15
}
},
{
"id": "q21",
"layout": "value_spinner",
"question": "school_years_transit",
"range": {
"min": 0,
"max": 15
}
},
{
"id": "q22",
"layout": "value_spinner",
"question": "school_years_germany",
"range": {
"min": 0,
"max": 15
}
},
{
"id": "q23",
"layout": "radio_question",
"question": "vocational_training",
"options": [
{ "key": "answer_no"},
{ "key": "answer_started"},
{ "key": "answer_completed"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q24",
"layout": "date_spinner",
"question": "provisional_accommodation_since",
"constraints": {
"notBefore": "since_in_germany"
}
},
{
"id": "q25",
"layout": "date_spinner",
"question": "asylum_procedure_since",
"constraints": {
"notBefore": "since_in_germany"
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,137 @@
{
"meta": {
"id": "questionnaire_2_rhs"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q3",
"layout": "glass_scale_question",
"question": "glass_explanation",
"symptoms": [
"q1_symptom",
"q2_symptom",
"q3_symptom",
"q4_symptom",
"q5_symptom",
"q6_symptom",
"q7_symptom",
"q8_symptom",
"q9_symptom"
]
},
{
"id": "q4",
"layout": "glass_scale_question",
"question": "how_strong_past_month",
"symptoms": [
"q10_reexperience_trauma",
"q11_physical_reaction",
"q12_emotional_numbness",
"q13_easily_startled"
]
},
{
"id": "q5",
"layout": "radio_question",
"textKey": "resilience_reflection_prompt",
"question": "q14_intro",
"options": [
{ "key": "resilience_fully_capable" },
{ "key": "resilience_mostly_capable" },
{ "key": "resilience_some_capable_some_not" },
{ "key": "resilience_mostly_not_capable" },
{ "key": "resilience_not_capable" }
],
"pointsMap": {
"resilience_fully_capable": 0,
"resilience_mostly_capable": 1,
"resilience_some_capable_some_not": 2,
"resilience_mostly_not_capable": 3,
"resilience_not_capable": 4
}
},
{
"id": "q6",
"layout": "value_spinner",
"question": "pain_rating_instruction",
"range": {
"min": 0,
"max": 10
}
},
{
"id": "q7",
"layout": "radio_question",
"question": "violence_question_1",
"options": [
{ "key": "no",
"nextQuestionId": "q10"},
{ "key": "yes" }
]
},
{
"id": "q8",
"layout": "radio_question",
"question": "times_happend",
"options": [
{ "key": "once" },
{ "key": "multiple_times" }
]
},
{
"id": "q9",
"layout": "value_spinner",
"question": "age_at_incident",
"range": {
"min": 1,
"max": 122
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "conflict_since_arrival",
"options": [
{ "key": "no",
"nextQuestionId": "last_page"},
{ "key": "yes" }
]
},
{
"id": "q11",
"layout": "radio_question",
"question": "times_happend2",
"options": [
{ "key": "once" },
{ "key": "multiple_times" }
]
},
{
"id": "q12",
"layout": "value_spinner",
"question": "age_at_incident2",
"range": {
"min": 1,
"max": 122
}
},
{
"id": "q25",
"layout": "date_spinner",
"question": "asylum_procedure_since"
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,255 @@
{
"meta": {
"id": "questionnaire_3_integration_index"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "radio_question",
"textKey": "intro_life_in_germany",
"question": "feeling_connected_to_germany_question",
"options": [
{ "key": "very_connected" },
{ "key": "quite_connected" },
{ "key": "moderately_connected" },
{ "key": "somewhat_loose" },
{ "key": "not_connected_at_all" }
],
"pointsMap": {
"very_connected": 3,
"quite_connected": 4,
"moderately_connected": 5,
"somewhat_loose": 1,
"not_connected_at_all": 2
}
},
{
"id": "q2",
"layout": "radio_question",
"question": "understanding_political_issues",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "fairly_good" },
{ "key": "somewhat" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 1,
"good": 2,
"fairly_good": 3,
"somewhat": 4,
"not_at_all": 5
}
},
{
"id": "q3",
"layout": "radio_question",
"question": "unexpected_expense_question",
"options": [
{ "key": "expense_50" },
{ "key": "expense_100" },
{ "key": "expense_300" },
{ "key": "expense_500" },
{ "key": "expense_800" }
],
"pointsMap": {
"expense_50": 1,
"expense_100": 2,
"expense_300": 3,
"expense_500": 4,
"expense_800": 5
}
},
{
"id": "q4",
"layout": "radio_question",
"question": "dining_with_germans_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 1,
"once_a_year": 2,
"once_a_month": 3,
"once_a_week": 4,
"almost_every_day": 5
}
},
{
"id": "q5",
"layout": "radio_question",
"question": "reading_german_articles_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "moderately_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 1,
"good": 2,
"moderately_good": 3,
"not_good": 4,
"not_at_all": 5
}
},
{
"id": "q6",
"layout": "radio_question",
"question": "visiting_doctor_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 1,
"rather_difficult": 2,
"neither_nor": 3,
"rather_easy": 4,
"very_easy": 5
}
},
{
"id": "q7",
"layout": "radio_question",
"question": "feeling_as_outsider_question",
"options": [
{ "key": "never" },
{ "key": "rarely" },
{ "key": "sometimes" },
{ "key": "often" },
{ "key": "always" }
],
"pointsMap": {
"never": 1,
"rarely": 2,
"sometimes": 3,
"often": 4,
"always": 5
}
},
{
"id": "q8",
"layout": "radio_question",
"question": "recent_occupation_question",
"options": [
{ "key": "employed" },
{ "key": "education" },
{ "key": "internship" },
{ "key": "unemployed_searching" },
{ "key": "unemployed_not_searching" },
{ "key": "sick_or_disabled" },
{ "key": "unpaid_housework" },
{ "key": "other_activity" }
],
"pointsMap": {
"employed": 1,
"education": 2,
"internship": 3,
"unemployed_searching": 4,
"unemployed_not_searching": 8,
"sick_or_disabled": 5,
"unpaid_housework": 6,
"other_activity": 7
}
},
{
"id": "q9",
"layout": "radio_question",
"question": "discussing_politics_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 1,
"once_a_year": 2,
"once_a_month": 3,
"once_a_week": 4,
"almost_every_day": 5
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "contact_with_germans_question",
"options": [
{ "key": "zero" },
{ "key": "one_to_three" },
{ "key": "three_to_six" },
{ "key": "seven_to_fourteen" },
{ "key": "fifteen_or_more" }
],
"pointsMap": {
"zero": 1,
"one_to_three": 2,
"three_to_six": 3,
"seven_to_fourteen": 4,
"fifteen_or_more": 5
}
},
{
"id": "q11",
"layout": "radio_question",
"question": "speaking_german_opinion_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "moderately_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 1,
"good": 2,
"moderately_good": 3,
"not_good": 4,
"not_at_all": 5
}
},
{
"id": "q12",
"layout": "radio_question",
"question": "job_search_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 1,
"rather_difficult": 2,
"neither_nor": 3,
"rather_easy": 4,
"very_easy": 5
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,182 @@
{
"meta": {
"id": "questionnaire_4_consultation_results"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "date_spinner",
"question": "date_consultation_health_interview_result"
},
{
"id": "q2",
"layout": "radio_question",
"question": "consultation_decision",
"options": [
{
"key": "green",
"nextQuestionId": "q3"},
{"key": "yellow",
"nextQuestionId": "q4"},
{ "key": "red",
"nextQuestionId": "q9"}
],
"pointsMap": {
"green": 0,
"yellow": 0,
"red": 0
}
},
{
"id": "q3",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q4",
"layout": "radio_question",
"question": "participation_in_coaching",
"options": [
{"key": "yes",
"nextQuestionId": "q5"},
{"key": "no",
"nextQuestionId": "q6"},
{"key": "time_to_think_about_it",
"nextQuestionId": "q7"}
],
"pointsMap": {
"yes": 0,
"no": 0,
"time_to_think_about_it": 0
}
},
{
"id": "q5",
"layout": "radio_question",
"question": "consent_coaching_given",
"options": [
{"key": "consent_yes",
"nextQuestionId": "q3"},
{"key": "consent_no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"consent_yes": 0,
"consent_no": 0
}
},
{
"id": "q6",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q7",
"layout": "date_spinner",
"question": "decision_after_reflection_period"
},
{
"id": "q8",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q9",
"layout": "radio_question",
"question": "professional_referral",
"options": [
{ "key": "yes"},
{ "key": "no"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "confidentiality_agreement" ,
"options": [
{ "key": "available_yes"},
{ "key": "available_no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q11",
"layout": "radio_question",
"question": "health_insurance_card",
"options": [
{ "key": "yes"},
{ "key": "no"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q12",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,84 @@
{
"meta": {
"id": "questionnaire_5_final_interview"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "radio_question",
"question": "consent_followup_6_months",
"options": [
{ "key": "yes" },
{ "key": "no" }
]
},
{
"id": "q2",
"layout": "date_spinner",
"question": "date_final_interview"
},
{
"id": "q3",
"layout": "value_spinner",
"question": "amount_nat_appointments",
"range": {
"min": 0,
"max": 20
}
},
{
"id": "q4",
"layout": "value_spinner",
"question": "amount_session_flowers",
"range": {
"min": 0,
"max": 20
}
},
{
"id": "q5",
"layout": "value_spinner",
"question": "amount_session_stones",
"range": {
"min": 0,
"max": 20
}
},
{
"id": "q6",
"layout": "radio_question",
"question": "termination_nat_coaching",
"options": [
{"key": "termination_nat_regular",
"nextQuestionId": "last_page"},
{"key": "termination_nat_referral",
"nextQuestionId": "last_page"},
{"key": "termination_nat_dropout",
"nextQuestionId": "q7"}
]
},
{
"id": "q7",
"layout": "radio_question",
"question": "client_canceled_NAT",
"options": [
{"key": "after_meeting"},
{"key": "without_giving_reasons"},
{"key": "with_reasons_given"}
]
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,349 @@
{
"meta": {
"id": "questionnaire_6_follow_up_survey"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "radio_question",
"question": "follow_up",
"options": [
{
"key": "follow_up_completed",
"nextQuestionId": "q2"
},
{
"key": "follow_up_failed_contact",
"nextQuestionId": "last_page"
},
{
"key": "follow_up_consent_withdrawn",
"nextQuestionId": "last_page"
}
],
"pointsMap": {
"follow_up_completed": 0,
"follow_up_failed_contact": 0,
"follow_up_consent_withdrawn": 0
}
},
{
"id": "q2",
"layout": "radio_question",
"question": "special_burden_question",
"options": [
{ "key": "yes" },
{ "key": "no" }
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q3",
"layout": "glass_scale_question",
"question": "glass_explanation",
"symptoms": [
"q1_symptom",
"q2_symptom",
"q3_symptom",
"q4_symptom",
"q5_symptom",
"q6_symptom",
"q7_symptom",
"q8_symptom",
"q9_symptom"
]
},
{
"id": "q4",
"layout": "glass_scale_question",
"question": "how_strong_past_month",
"symptoms": [
"q10_reexperience_trauma",
"q11_physical_reaction",
"q12_emotional_numbness",
"q13_easily_startled"
]
},
{
"id": "q5",
"layout": "radio_question",
"textKey": "resilience_reflection_prompt",
"question": "q14_intro",
"options": [
{ "key": "resilience_fully_capable" },
{ "key": "resilience_mostly_capable" },
{ "key": "resilience_some_capable_some_not" },
{ "key": "resilience_mostly_not_capable" },
{ "key": "resilience_not_capable" }
],
"pointsMap": {
"resilience_fully_capable": 0,
"resilience_mostly_capable": 0,
"resilience_some_capable_some_not": 0,
"resilience_mostly_not_capable": 0,
"resilience_not_capable": 0
}
},
{
"id": "q6",
"layout": "value_spinner",
"question": "pain_rating_instruction",
"range": {
"min": 0,
"max": 10
}
},
{
"id": "q7",
"layout": "radio_question",
"textKey": "intro_life_in_germany",
"question": "feeling_connected_to_germany_question",
"options": [
{ "key": "very_connected" },
{ "key": "quite_connected" },
{ "key": "moderately_connected" },
{ "key": "somewhat_loose" },
{ "key": "not_connected_at_all" }
],
"pointsMap": {
"very_connected": 0,
"quite_connected": 0,
"moderately_connected": 0,
"somewhat_loose": 0,
"not_connected_at_all": 0
}
},
{
"id": "q8",
"layout": "radio_question",
"question": "understanding_political_issues",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "fairly_good" },
{ "key": "somewhat" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 0,
"good": 0,
"fairly_good": 0,
"somewhat": 0,
"not_at_all": 0
}
},
{
"id": "q9",
"layout": "radio_question",
"question": "unexpected_expense_question",
"options": [
{ "key": "expense_50" },
{ "key": "expense_100" },
{ "key": "expense_300" },
{ "key": "expense_500" },
{ "key": "expense_800" }
],
"pointsMap": {
"expense_50": 0,
"expense_100": 0,
"expense_300": 0,
"expense_500": 0,
"expense_800": 0
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "dining_with_germans_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 0,
"once_a_year": 0,
"once_a_month": 0,
"once_a_week": 0,
"almost_every_day": 0
}
},
{
"id": "q11",
"layout": "radio_question",
"question": "reading_german_articles_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "moderately_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 0,
"good": 0,
"moderately_good": 0,
"not_good": 0,
"not_at_all": 0
}
},
{
"id": "q12",
"layout": "radio_question",
"question": "visiting_doctor_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 0,
"rather_difficult": 0,
"neither_nor": 0,
"rather_easy": 0,
"very_easy": 0
}
},
{
"id": "q13",
"layout": "radio_question",
"question": "feeling_as_outsider_question",
"options": [
{ "key": "never" },
{ "key": "rarely" },
{ "key": "sometimes" },
{ "key": "often" },
{ "key": "always" }
],
"pointsMap": {
"never": 0,
"rarely": 0,
"sometimes": 0,
"often": 0,
"always": 0
}
},
{
"id": "q14",
"layout": "radio_question",
"question": "recent_occupation_question",
"options": [
{ "key": "employed" },
{ "key": "education" },
{ "key": "internship" },
{ "key": "unemployed_searching" },
{ "key": "unemployed_not_searching" },
{ "key": "sick_or_disabled" },
{ "key": "unpaid_housework" },
{ "key": "other_activity" }
],
"pointsMap": {
"employed": 0,
"education": 0,
"internship": 0,
"unemployed_searching": 0,
"unemployed_not_searching": 0,
"sick_or_disabled": 0,
"unpaid_housework": 0,
"other_activity": 0
}
},
{
"id": "q15",
"layout": "radio_question",
"question": "discussing_politics_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 0,
"once_a_year": 0,
"once_a_month": 0,
"once_a_week": 0,
"almost_every_day": 0
}
},
{
"id": "q16",
"layout": "radio_question",
"question": "contact_with_germans_question",
"options": [
{ "key": "zero" },
{ "key": "one_to_three" },
{ "key": "three_to_six" },
{ "key": "seven_to_fourteen" },
{ "key": "fifteen_or_more" }
],
"pointsMap": {
"zero": 0,
"one_to_three": 0,
"three_to_six": 0,
"seven_to_fourteen": 0,
"fifteen_or_more": 0
}
},
{
"id": "q17",
"layout": "radio_question",
"question": "speaking_german_opinion_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "fairly_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 0,
"good": 0,
"fairly_good": 0,
"not_good": 0,
"not_at_all": 0
}
},
{
"id": "q18",
"layout": "radio_question",
"question": "job_search_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 0,
"rather_difficult": 0,
"neither_nor": 0,
"rather_easy": 0,
"very_easy": 0
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,67 @@
[
{
"file": "questionnaire_1_demographic_information.json",
"showPoints": false,
"condition": {
"alwaysAvailable": true
}
},
{
"file": "questionnaire_2_rhs.json",
"showPoints": true,
"condition": {
"alwaysAvailable": true
}
},
{
"file": "questionnaire_3_integration_index.json",
"showPoints": true,
"condition": {
"alwaysAvailable": true
}
},
{
"file": "questionnaire_4_consultation_results.json",
"showPoints": false,
"condition": {
"requiresCompleted": [
"questionnaire_1_demographic_information",
"questionnaire_2_rhs",
"questionnaire_3_integration_index"
],
"questionnaire": "questionnaire_1_demographic_information",
"questionId": "consent_instruction",
"operator": "==",
"value": "consent_signed"
}
},
{
"file": "questionnaire_5_final_interview.json",
"showPoints": false,
"condition": {
"requiresCompleted": ["questionnaire_4_consultation_results"],
"questionnaire": "questionnaire_4_consultation_results",
"questionId": "consultation_decision",
"operator": "==",
"value": "yellow"
}
},
{
"file": "questionnaire_6_follow_up_survey.json",
"showPoints": true,
"condition": {
"anyOf": [
{
"requiresCompleted": ["questionnaire_5_final_interview"]
},
{
"requiresCompleted": ["questionnaire_4_consultation_results"],
"questionnaire": "questionnaire_4_consultation_results",
"questionId": "consultation_decision",
"operator": "!=",
"value": "yellow"
}
]
}
}
]