removed legacy files

This commit is contained in:
2026-05-27 18:32:00 +02:00
parent 0f98393d1d
commit 9c887537e8
45 changed files with 91 additions and 6067 deletions

View File

@ -1,185 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
$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"]);
}

View File

@ -1,160 +0,0 @@
<?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 -> global app UI strings only; **no auth** (login screen).
*
* POST (coach interview upload) is handled by api/index.php -> handlers/app_questionnaires.php
*/
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
require_once __DIR__ . '/../lib/response.php';
header('Content-Type: application/json; charset=UTF-8');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET' && !empty($_GET['translations'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
qdb_discard($tmpDb, $lockFp);
exit;
}
$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'] ?? '';
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 = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$q = [
'id' => $shortId,
'layout' => $layout,
'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
];
if ($qKey !== '' && !empty($config['noteBefore'])) {
$q['noteBeforeKey'] = qdb_note_before_key($qKey);
}
if ($qKey !== '' && !empty($config['noteAfter'])) {
$q['noteAfterKey'] = qdb_note_after_key($qKey);
}
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['scaleType'])) $q['scaleType'] = $config['scaleType'];
if (isset($config['range'])) $q['range'] = $config['range'];
if (isset($config['step'])) $q['step'] = (int)$config['step'];
if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel'];
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
if (isset($config['precision'])) $q['precision'] = $config['precision'];
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength'];
if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId'];
if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId'];
if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey'];
if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options'];
}
if (($layout === 'value_spinner' || $layout === 'slider_question') && 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,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]);
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);

View File

@ -1,116 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
$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"]);

View File

@ -1,152 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
$tokenRec = require_valid_token_web();
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;
}
if (!empty($_GET['bundle'])) {
$role = $tokenRec['role'] ?? '';
if (!in_array($role, ['admin', 'supervisor'], true)) {
header('Content-Type: application/json; charset=UTF-8');
http_response_code(403);
echo json_encode(["error" => "Permission denied"]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$bundle = qdb_export_all_questionnaires_bundle($pdo);
qdb_discard($tmpDb, $lockFp);
$filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json';
header('Content-Type: application/json; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
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, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
$qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
$resultColumns = qdb_results_export_columns($questions, $qnID);
// 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,
co.username AS coachUsername,
sv.username AS supervisorUsername,
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
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
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'],
'coach' => $c['coachUsername'] ?? $c['coachID'],
'supervisor' => $c['supervisorUsername'] ?? '',
'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 ($resultColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
}
$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', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($resultColumns as $col) {
$header[] = $col['header'];
}
fputcsv($out, $header);
}
fclose($out);

View File

@ -48,7 +48,6 @@ $routes = [
'backup' => __DIR__ . '/../handlers/backup.php',
'dev' => __DIR__ . '/../handlers/dev.php',
'dev/import' => __DIR__ . '/../handlers/dev.php',
'download' => __DIR__ . '/../handlers/download.php',
];
try {

View File

@ -1,21 +0,0 @@
<?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]);

View File

@ -1,169 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
$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"]);
}

View File

@ -1,215 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
$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"]);
}

View File

@ -1,143 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
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,
co.username AS coachUsername,
sv.username AS supervisorUsername,
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
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
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
]);

View File

@ -1,291 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
$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"]);
}

View File

@ -1,274 +0,0 @@
<?php
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token_web();
$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"]);

View File

@ -1,117 +0,0 @@
<?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_web();
$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"]);

View File

@ -1,42 +0,0 @@
<?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_web();
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",
]);

View File

@ -1,113 +0,0 @@
<?php
// /var/www/html/change_password.php
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$raw = file_get_contents("php://input");
$in = json_decode($raw, true) ?: [];
$username = trim($in['username'] ?? '');
$oldPassword = (string)($in['old_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 === '') {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Missing fields"]);
exit;
}
if (strlen($newPassword) < 6) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "New password too short (min 6 characters)."]);
exit;
}
// Verify the token belongs to the user requesting the change
if (($tokenRec['userID'] ?? '') === '') {
http_response_code(403);
echo json_encode(["success" => false, "message" => "Token not associated with a user"]);
exit;
}
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);
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 (($user['role'] ?? '') === 'coach') {
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode([
"success" => false,
"message" => "Coach accounts can only sign in through the mobile app.",
]);
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,
"message" => "Password changed.",
"token" => $token,
"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,10 +0,0 @@
<?php
header('Content-Type: application/json');
$dbPath = __DIR__ . '/uploads/questionnaire_database';
if (file_exists($dbPath)) {
echo json_encode(["exists" => true]);
} else {
echo json_encode(["exists" => false]);
}

View File

@ -1,46 +0,0 @@
<?php
// /var/www/html/db_download.php
require_once __DIR__ . '/db_init.php';
$tokenRec = require_valid_token_web();
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
$enc = file_get_contents($dbPath);
if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
try {
$plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes());
} catch (Throwable $e) {
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);
}
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="questionnaire.sqlite"');
header('Content-Length: ' . strlen($plain));
echo $plain;

View File

@ -1,78 +0,0 @@
<?php
// /var/www/html/db_view.php
require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8');
$tokenRec = require_valid_token_web();
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
$enc = file_get_contents($dbPath);
if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
try {
$plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes());
} catch (Throwable $e) {
http_response_code(500); echo "Decryption failed"; exit;
}
$tmp = tempnam(sys_get_temp_dir(), 'qdb_');
file_put_contents($tmp, $plain);
try {
$pdo = new PDO("sqlite:$tmp");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$tables = [];
$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'];
// 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 "<p><b>Tables:</b> " . htmlspecialchars(implode(', ', array_diff($tables, $hidden))) . "</p>";
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
foreach ($tables as $t) {
if (in_array($t, $hidden, true)) continue;
echo "<h2>" . htmlspecialchars($t) . "</h2>";
$t_esc = preg_replace('/[^A-Za-z0-9_]/', '', $t);
$cols = [];
$cr = $pdo->query("PRAGMA table_info($t_esc)");
foreach ($cr as $c) $cols[] = $c['name'];
$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");
}
echo "<table><thead><tr>";
foreach ($cols as $c) echo "<th>" . htmlspecialchars($c) . "</th>";
echo "</tr></thead><tbody>";
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo "<tr>";
foreach ($cols as $c) {
$val = $row[$c];
if ($val === null) { echo "<td><em>NULL</em></td>"; }
else { echo "<td>" . htmlspecialchars((string)$val) . "</td>"; }
}
echo "</tr>";
}
echo "</tbody></table>";
}
} catch (Throwable $e) {
error_log($e->getMessage());
http_response_code(500); echo "Server error";
} finally {
@unlink($tmp);
}

View File

@ -1,156 +0,0 @@
<?php
// /var/www/html/db_view_ordered.php
require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8');
$tokenRec = require_valid_token_web();
$ORDER_JSON = __DIR__ . '/header_order.json';
$LABELS_JSON = __DIR__ . '/questions_en.json';
$VALS_JSON = __DIR__ . '/translations_en.json';
$order = @json_decode(@file_get_contents($ORDER_JSON), true);
$labels = @json_decode(@file_get_contents($LABELS_JSON), true);
$vals = @json_decode(@file_get_contents($VALS_JSON), true);
if (!is_array($order) || empty($order)) { http_response_code(500); echo "header_order.json missing or empty."; exit; }
if (!is_array($labels)) $labels = [];
if (!is_array($vals)) $vals = [];
function qdb_header_question_key(string $headerId): string {
if ($headerId === 'client_code') {
return 'client_code';
}
$dash = strpos($headerId, '-');
if ($dash === false) {
return $headerId;
}
return substr($headerId, $dash + 1);
}
function t_val(string $id, string $raw, array $vals): string {
if ($raw === '' || $raw === 'None' || $raw === 'Done' || $raw === 'Not Done') return $raw;
$norm = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $raw));
$cands = [$raw];
if ($norm && $norm !== $raw) $cands[] = $norm;
$cands[] = "{$id}_{$raw}";
$cands[] = "{$id}-{$raw}";
$cands[] = "{$id}_{$norm}";
$cands[] = "{$id}-{$norm}";
foreach ($cands as $k) {
if (isset($vals[$k]) && is_string($vals[$k]) && $vals[$k] !== '') return $vals[$k];
}
if (isset($vals[$norm])) return (string)$vals[$norm];
if (isset($vals[$raw])) return (string)$vals[$raw];
return $raw;
}
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
$enc = file_get_contents($dbPath);
if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
try { $plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes()); }
catch (Throwable $e) { http_response_code(500); echo "Decryption failed"; exit; }
$tmp = tempnam(sys_get_temp_dir(), 'qdb_');
file_put_contents($tmp, $plain);
try {
$pdo = new PDO("sqlite:$tmp");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$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);
$completed = [];
$cqStmt = $pdo->prepare(
"SELECT cq.clientCode, cq.questionnaireID, cq.status
FROM completed_questionnaire cq
JOIN client ON client.clientCode = cq.clientCode
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 = [];
$ansStmt = $pdo->prepare(
"SELECT ca.clientCode, ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue
FROM client_answer ca
JOIN client ON client.clientCode = ca.clientCode
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;
}
echo '<style>
#qdb-table{border-collapse:separate;border-spacing:0;width:100%;}
#qdb-table th,#qdb-table td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top;}
#qdb-table thead tr:nth-child(1) th{position:sticky;top:0;z-index:4;background:#f7f7f7;box-shadow:0 1px 0 #ddd;}
#qdb-table thead tr:nth-child(2) th{position:sticky;top:var(--head1h,34px);z-index:3;background:#fafafa;box-shadow:0 1px 0 #ddd;}
#qdb-table thead tr:nth-child(1) th.chkcol{z-index:5;}
.nowrap{white-space:nowrap;}
.chkcol{width:36px;text-align:center;}
</style>';
echo '<table id="qdb-table">';
echo '<thead>';
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>';
echo '</tr>';
echo '<tr><th class="chkcol"></th>';
foreach ($order as $id) {
$key = qdb_header_question_key($id);
$lbl = $labels[$id] ?? '';
$title = $lbl !== '' ? $lbl : $id;
echo '<th class="col-key" title="'.htmlspecialchars($title).'">'.htmlspecialchars($key).'</th>';
}
echo '</tr></thead><tbody>';
foreach ($clients as $client) {
$rowClass = stripos($client, 'DEV-CL-') === 0 ? ' row-test-data' : '';
echo '<tr class="'.trim($rowClass).'" data-client="'.htmlspecialchars($client).'">';
echo '<td class="chkcol"><input type="checkbox" class="rowchk"></td>';
foreach ($order as $id) {
if ($id === 'client_code') {
$val = $client;
} elseif (isset($qidSet[$id]) && strpos($id, '-') === false) {
$status = $completed[$client][$id] ?? '';
$val = ($status === 'done' || $status === 'Done') ? 'Done' : ($status !== '' ? $status : 'Not Done');
} else {
$raw = $answers[$client][$id] ?? '';
$val = (strlen(trim($raw)) > 0) ? t_val($id, $raw, $vals) : 'None';
}
echo '<td>'.htmlspecialchars($val).'</td>';
}
echo '</tr>';
}
echo '</tbody></table>';
} catch (Throwable $e) {
http_response_code(500);
error_log($e->getMessage());
echo "Server error";
} finally {
@unlink($tmp);
}

View File

@ -1,38 +0,0 @@
<?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

@ -315,7 +315,7 @@ Android should apply this map while a questionnaire is open (overlay on top of a
4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
6. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` (plaintext JSON over HTTPS; server stores answers in the database — not the app's local ciphertext).
8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` using an **encrypted** JSON envelope (see above).
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available.
@ -327,7 +327,31 @@ The Android app encrypts sensitive data **at rest on the device**:
- **Assigned client list** cache and **session token** use the same encryption / EncryptedSharedPreferences respectively.
- **Questionnaire definitions** and UI translations cached from this API are not encrypted (no user answers).
After a successful `POST /api/app_questionnaires`, answer data is readable on the **server** (normal database storage, RBAC-protected). The upload body is **decrypted plaintext** (AES-GCM applies only to the on-device Room database). The app keeps encrypted local copies so coaches can review, edit, and re-upload work offline.
Sensitive mobile traffic uses **two layers**:
1. **HTTPS** (TLS) for transport.
2. **Application payload encryption** for patient-related fields: AES-256-CBC with a per-session key derived from the Bearer token via HKDF-SHA256 (`info` = `qdb-aes`), matching the legacy QDB mobile crypto helpers.
Encrypted wire format (request or response `data`):
```json
{
"encrypted": true,
"payload": "<base64( IV16 || ciphertext )>"
}
```
Endpoints using encrypted payloads:
| Endpoint | Direction |
|----------|-----------|
| `POST /api/auth/login` | Response: `clientsPayload` (when coach has clients) |
| `GET /api/app_questionnaires?clients=1` | Response: entire `data` object encrypted |
| `POST /api/app_questionnaires` | Request body must be encrypted |
Plain JSON (no app-layer encryption): `GET ?translations=1`, questionnaire list, questionnaire detail.
The server decrypts uploads before writing to the database. The web dashboard reads **server-side** plaintext (RBAC). The Android app also keeps **AES-GCM at rest** on device via Android Keystore (separate from wire encryption).
Re-uploading the same `clientCode` + `questionnaireID` updates server rows in place (see upload behavior below). This matches upcoming editable flows on web and mobile.

View File

@ -1,51 +0,0 @@
<?php
// /var/www/html/downloadFull.php
require_once __DIR__ . '/db_init.php';
$tokenRec = require_valid_token_web();
$token = $tokenRec['_token'];
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
$encMaster = file_get_contents($dbPath);
if ($encMaster === false) { http_response_code(500); echo "Read error"; exit; }
$masterKey = get_master_key_bytes();
try {
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
} catch (Throwable $e) {
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);
}
}
$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;

View File

@ -7,7 +7,7 @@
* GET ?clients=1 -> list of clients assigned to the authenticated coach
* POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
* Mobile clients encrypt answers at rest on device; POST body is plaintext JSON over HTTPS.
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
*/
// Public app UI catalog for the login screen (no token, no PII).
@ -23,7 +23,7 @@ $tokenRec = require_valid_token();
if ($method === 'POST') {
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
$body = read_json_body();
$body = read_encrypted_json_body($tokenRec['_token']);
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']);
@ -259,7 +259,7 @@ if ($fetchClients) {
}, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success(['coachID' => $coachID, 'clients' => $clients]);
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
}
if ($qnID) {

View File

@ -67,12 +67,18 @@ case 'auth/login':
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
json_success([
'token' => $token,
'user' => $username,
'role' => $user['role'],
'clients' => $assignedClients,
]);
$loginData = [
'token' => $token,
'user' => $username,
'role' => $user['role'],
];
if ($assignedClients !== []) {
$loginData['clientsPayload'] = qdb_sensitive_envelope(
json_encode(['clients' => $assignedClients], JSON_UNESCAPED_UNICODE),
$token
);
}
json_success($loginData);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());

View File

@ -1,73 +0,0 @@
<?php
$tokenRec = require_valid_token_web();
$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;

View File

@ -1,106 +0,0 @@
[
"client_code",
"questionnaire_1_demographic_information",
"questionnaire_1_demographic_information-coach_code",
"questionnaire_1_demographic_information-consent_instruction",
"questionnaire_1_demographic_information-no_consent_entered",
"questionnaire_1_demographic_information-accommodation",
"questionnaire_1_demographic_information-other_accommodation",
"questionnaire_1_demographic_information-client_code_entry_question",
"questionnaire_1_demographic_information-age",
"questionnaire_1_demographic_information-gender",
"questionnaire_1_demographic_information-country_of_origin",
"questionnaire_1_demographic_information-departure_country",
"questionnaire_1_demographic_information-since_in_germany",
"questionnaire_1_demographic_information-living_situation",
"questionnaire_1_demographic_information-number_family_members",
"questionnaire_1_demographic_information-languages_spoken",
"questionnaire_1_demographic_information-german_skills",
"questionnaire_1_demographic_information-school_years_total",
"questionnaire_1_demographic_information-school_years_origin",
"questionnaire_1_demographic_information-school_years_transit",
"questionnaire_1_demographic_information-school_years_germany",
"questionnaire_1_demographic_information-vocational_training",
"questionnaire_1_demographic_information-provisional_accommodation_since",
"questionnaire_2_rhs",
"questionnaire_2_rhs-coach_code",
"questionnaire_2_rhs-glass_explanation",
"questionnaire_2_rhs-q1_symptom",
"questionnaire_2_rhs-q2_symptom",
"questionnaire_2_rhs-q3_symptom",
"questionnaire_2_rhs-q4_symptom",
"questionnaire_2_rhs-q5_symptom",
"questionnaire_2_rhs-q6_symptom",
"questionnaire_2_rhs-q7_symptom",
"questionnaire_2_rhs-q8_symptom",
"questionnaire_2_rhs-q9_symptom",
"questionnaire_2_rhs-q10_reexperience_trauma",
"questionnaire_2_rhs-q11_physical_reaction",
"questionnaire_2_rhs-q12_emotional_numbness",
"questionnaire_2_rhs-q13_easily_startled",
"questionnaire_2_rhs-q14_intro",
"questionnaire_2_rhs-pain_rating_instruction",
"questionnaire_2_rhs-violence_question_1",
"questionnaire_2_rhs-times_happend",
"questionnaire_2_rhs-age_at_incident",
"questionnaire_2_rhs-conflict_since_arrival",
"questionnaire_2_rhs-times_happend2",
"questionnaire_2_rhs-age_at_incident2",
"questionnaire_2_rhs-asylum_procedure_since",
"questionnaire_3_integration_index",
"questionnaire_3_integration_index-coach_code",
"questionnaire_3_integration_index-feeling_connected_to_germany_question",
"questionnaire_3_integration_index-understanding_political_issues",
"questionnaire_3_integration_index-unexpected_expense_question",
"questionnaire_3_integration_index-dining_with_germans_question",
"questionnaire_3_integration_index-reading_german_articles_question",
"questionnaire_3_integration_index-visiting_doctor_question",
"questionnaire_3_integration_index-feeling_as_outsider_question",
"questionnaire_3_integration_index-recent_occupation_question",
"questionnaire_3_integration_index-discussing_politics_question",
"questionnaire_3_integration_index-contact_with_germans_question",
"questionnaire_3_integration_index-speaking_german_opinion_question",
"questionnaire_3_integration_index-job_search_question",
"questionnaire_4_consultation_results",
"questionnaire_4_consultation_results-coach_code",
"questionnaire_4_consultation_results-date_consultation_health_interview_result",
"questionnaire_4_consultation_results-consultation_decision",
"questionnaire_4_consultation_results-consent_conversation_in_6_months",
"questionnaire_4_consultation_results-participation_in_coaching",
"questionnaire_4_consultation_results-consent_coaching_given",
"questionnaire_4_consultation_results-consent_conversation_in_6_months",
"questionnaire_4_consultation_results-decision_after_reflection_period",
"questionnaire_4_consultation_results-professional_referral",
"questionnaire_4_consultation_results-confidentiality_agreement",
"questionnaire_4_consultation_results-health_insurance_card",
"questionnaire_4_consultation_results-consent_conversation_in_6_months",
"questionnaire_5_final_interview",
"questionnaire_5_final_interview-coach_code",
"questionnaire_5_final_interview-consent_followup_6_months",
"questionnaire_5_final_interview-date_final_interview",
"questionnaire_5_final_interview-amount_nat_appointments",
"questionnaire_5_final_interview-amount_session_flowers",
"questionnaire_5_final_interview-amount_session_stones",
"questionnaire_5_final_interview-termination_nat_coaching",
"questionnaire_5_final_interview-client_canceled_NAT",
"questionnaire_6_follow_up_survey",
"questionnaire_6_follow_up_survey-coach_code",
"questionnaire_6_follow_up_survey-follow_up",
"questionnaire_6_follow_up_survey-special_burden_question",
"questionnaire_6_follow_up_survey-glass_explanation",
"questionnaire_6_follow_up_survey-how_strong_past_month",
"questionnaire_6_follow_up_survey-q14_intro",
"questionnaire_6_follow_up_survey-pain_rating_instruction",
"questionnaire_6_follow_up_survey-feeling_connected_to_germany_question",
"questionnaire_6_follow_up_survey-understanding_political_issues",
"questionnaire_6_follow_up_survey-unexpected_expense_question",
"questionnaire_6_follow_up_survey-dining_with_germans_question",
"questionnaire_6_follow_up_survey-reading_german_articles_question",
"questionnaire_6_follow_up_survey-visiting_doctor_question",
"questionnaire_6_follow_up_survey-feeling_as_outsider_question",
"questionnaire_6_follow_up_survey-recent_occupation_question",
"questionnaire_6_follow_up_survey-discussing_politics_question",
"questionnaire_6_follow_up_survey-contact_with_germans_question",
"questionnaire_6_follow_up_survey-speaking_german_opinion_question",
"questionnaire_6_follow_up_survey-job_search_question"
]

View File

@ -1,195 +0,0 @@
<?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_web();
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['scaleType'])) $config['scaleType'] = $q['scaleType'];
if (isset($q['range'])) $config['range'] = $q['range'];
if (isset($q['constraints'])) $config['constraints'] = $q['constraints'];
if (isset($q['precision'])) $config['precision'] = $q['precision'];
if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection'];
if (isset($q['maxLength'])) $config['maxLength'] = (int)$q['maxLength'];
if (isset($q['nextQuestionId'])) $config['nextQuestionId'] = $q['nextQuestionId'];
if (isset($q['otherNextQuestionId'])) $config['otherNextQuestionId'] = $q['otherNextQuestionId'];
if (isset($q['otherOptionKey'])) $config['otherOptionKey'] = $q['otherOptionKey'];
if (isset($q['config']) && is_array($q['config'])) {
foreach (['nextQuestionId', 'otherNextQuestionId', 'otherOptionKey', 'hint', 'maxLength', 'textKey', 'precision', 'scaleType', 'noteBefore', 'noteAfter', 'step', 'unitLabel'] as $ck) {
if (isset($q['config'][$ck])) {
$config[$ck] = $q['config'][$ck];
}
}
}
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;
}
}
if ($qKey !== '') {
$config = qdb_normalize_question_config($config, $qKey);
}
$configJson = !empty($config) ? json_encode($config) : '{}';
$germanText = $qKey;
$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' => $germanText,
':ty' => $layout,
':oi' => $qIdx,
':ir' => 0,
':cj' => $configJson,
]);
if ($qKey !== '') {
qdb_upsert_source_translation($pdo, 'question', $questionID, $germanText);
qdb_sync_question_note_strings($pdo, $qKey, $config);
}
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

@ -1,975 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Questionnaire Database</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { font-family: system-ui, sans-serif; margin: 20px; }
.card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin-bottom: 16px; width: 100%; box-sizing: border-box; }
.hidden { display: none; }
.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; font-size:14px; }
button:hover, a.button:hover { background:#f7f7f7; }
.muted { color:#666; }
.role-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:600; margin-left:6px; }
.role-admin { background:#e3f2fd; color:#1565c0; }
.role-supervisor { background:#fff3e0; color:#e65100; }
.role-coach { background:#e8f5e9; color:#2e7d32; }
.table-wrap { overflow: auto; border-radius: 8px; -webkit-overflow-scrolling: touch; }
#qdb-table { border-collapse: separate; border-spacing: 0; width: 100%; margin: 0; }
#qdb-table th, #qdb-table 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: 4;
background: #f7f7f7; box-shadow: 0 1px 0 #ddd;
}
#qdb-table thead tr:nth-child(2) th {
position: sticky; top: var(--head1h, 34px); z-index: 3;
background: #fafafa; box-shadow: 0 1px 0 #ddd;
}
#qdb-table thead tr:nth-child(1) th.chkcol {
z-index: 5;
}
.row-highlight { animation: hi 1.2s ease-in-out 0s 1; }
@keyframes hi { 0% { background:#fff9c4; } 100% { background:transparent; } }
.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; }
#qdb-table th:nth-child(3), #qdb-table td:nth-child(3) { max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#qdb-table tbody tr.row-test-data td { background:#fffde7; }
#qdb-table tbody tr.row-test-data:hover td { background:#fff9c4; }
#qdb-table tbody tr.row-filter-hidden { display:none; }
#qdb-table th.col-highlight { outline:2px solid #2563eb; outline-offset:-2px; }
.db-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:10px; }
.db-toolbar input, .db-toolbar select { padding:6px 8px; border:1px solid #ccc; border-radius:8px; font-size:14px; }
.db-toolbar label { font-size:14px; }
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; }
/* Assignment panel */
.assign-grid { display:grid; grid-template-columns:1fr auto 1fr; gap:12px; align-items:start; margin-top:12px; }
.assign-list { border:1px solid #ddd; border-radius:8px; padding:8px; max-height:300px; overflow-y:auto; }
.assign-list h4 { margin:0 0 8px; }
.assign-item { padding:6px 8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; }
.assign-item:hover { background:#f5f5f5; }
.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>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
</head>
<body>
<h1>Questionnaire Database</h1>
<!-- LOGIN CARD -->
<div id="loginCard" class="card">
<h2>Login</h2>
<form id="loginForm" autocomplete="on">
<div class="form-group">
<label for="username">Username</label>
<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>
<p id="loginMsg"></p>
</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 class="actions">
<span>Signed in as <b id="who"></b><span id="roleBadge" class="role-badge"></span></span>
<button id="logoutBtn">Logout</button>
<button id="changePwBtn">Change Password</button>
<button id="exportSelectedBtn">Download</button>
<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">
<label for="findClient">Find client:</label>
<input id="findClient" type="text" list="clientList" placeholder="Client code" />
<datalist id="clientList"></datalist>
<button id="findBtn" type="button">Go</button>
</div>
</div>
<div class="db-toolbar" id="dbToolbar" style="display:none">
<label>Filter rows <input id="filterRows" type="search" placeholder="Client code contains…" style="min-width:200px"></label>
<label>Test data
<select id="testDataFilter">
<option value="">Show all</option>
<option value="test">Test only (DEV-CL-*)</option>
<option value="hide-test">Hide test data</option>
</select>
</label>
<label>Go to column
<input id="findColumn" type="text" list="columnKeys" placeholder="Question key…" style="min-width:160px">
<datalist id="columnKeys"></datalist>
</label>
<button type="button" id="findColumnBtn">Go</button>
<span id="visibleRowCount" class="muted"></span>
</div>
<div class="table-wrap" id="tableWrap">
<div id="dbContainer"><em>Loading...</em></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>
const $ = sel => document.querySelector(sel);
let _pendingUser = null;
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_token', token);
localStorage.setItem('qdb_role', role || '');
$('#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');
$('#changePwCard').classList.add('hidden');
$('#volChangePwCard').classList.add('hidden');
$('#assignCard').classList.add('hidden');
$('#manageUsersCard').classList.add('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();
}
function setLoggedOut() {
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_role');
_pendingUser = null;
$('#dbContainer').innerHTML = '';
$('#appCard').classList.add('hidden');
$('#changePwCard').classList.add('hidden');
$('#volChangePwCard').classList.add('hidden');
$('#assignCard').classList.add('hidden');
$('#manageUsersCard').classList.add('hidden');
$('#loginCard').classList.remove('hidden');
}
/* ---------- LOGIN ---------- */
async function login(evt) {
evt.preventDefault();
$('#loginMsg').textContent = 'Signing in...';
try {
const res = await fetch('login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: $('#username').value,
password: $('#password').value
})
});
const data = await res.json();
if (!res.ok || !data.success) {
$('#loginMsg').textContent = data.message || 'Login failed';
return;
}
if (data.role === 'coach') {
$('#loginMsg').textContent = 'Coach accounts can only sign in through the mobile app.';
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 = '';
setLoggedIn(data.user, data.token, data.role);
} catch (e) {
$('#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() {
const wrap = document.getElementById('tableWrap');
if (!wrap) return;
const rect = wrap.getBoundingClientRect();
const avail = window.innerHeight - rect.top - 16;
wrap.style.maxHeight = (avail > 200 ? avail : 200) + 'px';
wrap.style.overflow = 'auto';
const table = document.querySelector('#qdb-table');
if (table?.tHead?.rows?.[0]) {
const h1 = table.tHead.rows[0].getBoundingClientRect().height || 34;
table.style.setProperty('--head1h', `${Math.round(h1)}px`);
}
}
async function loadDbView() {
const token = localStorage.getItem('qdb_token');
if (!token) return setLoggedOut();
$('#dbContainer').innerHTML = '<em>Loading...</em>';
try {
const res = await fetch('db_view_ordered.php', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (res.status === 401 || res.status === 403) {
setLoggedOut();
alert('Session expired or invalid. Please sign in again.');
return;
}
const html = await res.text();
$('#dbContainer').innerHTML = html;
const table = document.querySelector('#qdb-table');
if (!table) return;
const selInfo = $('#selInfo');
function updateCount(){
const n = table.querySelectorAll('tbody .rowchk:checked').length;
selInfo.textContent = `(${n} selected)`;
}
const chkAll = table.querySelector('#chkAll');
if (chkAll) {
chkAll.addEventListener('change', () => {
table.querySelectorAll('tbody .rowchk').forEach(ch => ch.checked = chkAll.checked);
updateCount();
});
}
table.querySelectorAll('tbody .rowchk').forEach(ch => ch.addEventListener('change', updateCount));
updateCount();
$('#exportSelectedBtn').onclick = () => exportSelectedToXLSX();
buildClientSearchList();
buildColumnKeyList();
setupDbTableFilters();
const findInput = $('#findClient');
const findBtn = $('#findBtn');
findBtn.onclick = () => gotoClientRow(findInput.value.trim());
findInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); findBtn.click(); }
});
const colInput = $('#findColumn');
const colBtn = $('#findColumnBtn');
if (colBtn) colBtn.onclick = () => gotoColumn(colInput?.value?.trim() || '');
if (colInput) {
colInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); gotoColumn(colInput.value.trim()); }
});
}
sizeTableArea();
} catch (e) {
$('#dbContainer').innerHTML = '<b>Error:</b> ' + e.message;
}
}
window.addEventListener('resize', sizeTableArea);
/* ---------- EXPORT ---------- */
function exportSelectedToXLSX() {
const table = document.querySelector('#qdb-table');
if (!table) return alert('Table not found.');
const headRows = table.tHead.rows;
if (headRows.length < 2) return alert('Header rows missing.');
const ids = [];
for (let i=1; i<headRows[0].cells.length; i++) {
const th = headRows[0].cells[i];
ids.push(th.getAttribute('data-id') || th.textContent.trim());
}
const labels = [];
for (let i=1; i<headRows[1].cells.length; i++) {
labels.push(headRows[1].cells[i].textContent.trim());
}
const rows = Array.from(table.tBodies[0].rows).filter(tr => tr.querySelector('.rowchk')?.checked);
if (rows.length === 0) { alert('Please select at least one row.'); return; }
const aoa = [[...ids], [...labels]];
rows.forEach(tr => {
const arr = [];
for (let i=1; i<tr.cells.length; i++) arr.push(tr.cells[i].textContent.trim());
aoa.push(arr);
});
const ws = XLSX.utils.aoa_to_sheet(aoa);
ws['!cols'] = ids.map(()=>({wch:36}));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Headers');
XLSX.writeFile(wb, 'SelectedClients.xlsx');
}
/* ---------- DB TABLE FILTERS / NAV ---------- */
function isTestClientCode(code) {
return String(code || '').trim().toUpperCase().startsWith('DEV-CL-');
}
function buildColumnKeyList() {
const table = document.querySelector('#qdb-table');
const dl = document.querySelector('#columnKeys');
if (!table || !dl) return;
dl.innerHTML = '';
const head = table.tHead?.rows?.[0];
if (!head) return;
const keys = new Set();
for (let i = 1; i < head.cells.length; i++) {
const id = head.cells[i].getAttribute('data-id') || '';
if (!id) continue;
if (id === 'client_code') keys.add('client_code');
else {
const dash = id.indexOf('-');
keys.add(dash >= 0 ? id.slice(dash + 1) : id);
}
}
Array.from(keys).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.forEach(k => { const opt = document.createElement('option'); opt.value = k; dl.appendChild(opt); });
}
function setupDbTableFilters() {
const table = document.querySelector('#qdb-table');
const toolbar = $('#dbToolbar');
if (!table || !toolbar) return;
toolbar.style.display = '';
const filterInput = $('#filterRows');
const testFilter = $('#testDataFilter');
const countEl = $('#visibleRowCount');
function applyFilters() {
const q = (filterInput?.value || '').trim().toLowerCase();
const mode = testFilter?.value || '';
let visible = 0;
table.querySelectorAll('tbody tr').forEach(tr => {
const code = (tr.getAttribute('data-client') || tr.cells[1]?.textContent || '').trim();
const matchText = !q || code.toLowerCase().includes(q);
const isTest = isTestClientCode(code);
const matchTest = mode === 'test' ? isTest : mode === 'hide-test' ? !isTest : true;
const show = matchText && matchTest;
tr.classList.toggle('row-filter-hidden', !show);
if (show) visible++;
});
if (countEl) countEl.textContent = `${visible} row(s) visible`;
}
filterInput?.addEventListener('input', applyFilters);
testFilter?.addEventListener('change', applyFilters);
applyFilters();
}
function gotoColumn(query) {
if (!query) return;
const table = document.querySelector('#qdb-table');
if (!table) return;
const q = query.toLowerCase();
const head = table.tHead?.rows?.[0];
if (!head) return;
table.querySelectorAll('th.col-highlight').forEach(th => th.classList.remove('col-highlight'));
let targetIdx = -1;
for (let i = 1; i < head.cells.length; i++) {
const th = head.cells[i];
const id = (th.getAttribute('data-id') || '').toLowerCase();
const keyCell = table.tHead.rows[1]?.cells[i];
const key = (keyCell?.textContent || '').trim().toLowerCase();
if (id === q || key === q || id.endsWith('-' + q) || id.includes(q)) {
targetIdx = i;
th.classList.add('col-highlight');
th.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
break;
}
}
if (targetIdx < 0) {
alert('No column matching "' + query + '"');
return;
}
table.querySelectorAll('tbody td:nth-child(' + (targetIdx + 1) + ')').forEach(td => {
td.style.outline = '1px solid #93c5fd';
setTimeout(() => { td.style.outline = ''; }, 2000);
});
}
/* ---------- CLIENT SEARCH ---------- */
function buildClientSearchList() {
const table = document.querySelector('#qdb-table');
if (!table) return;
let clientCol = -1;
const thead = table.tHead;
const rows = thead ? thead.rows : [];
if (rows.length >= 2) {
const cells2 = Array.from(rows[1].cells).map(c => c.textContent.trim().toLowerCase());
clientCol = cells2.findIndex(t => t === 'client code' || t === 'client_code');
}
if (clientCol < 0 && rows.length >= 1) {
const cells1 = Array.from(rows[0].cells).map(c => (c.getAttribute('data-id') || c.textContent).trim().toLowerCase());
clientCol = cells1.findIndex(t => t === 'client code' || t === 'client_code');
}
if (clientCol < 0) return;
const dl = document.querySelector('#clientList');
dl.innerHTML = '';
const codes = new Set();
table.tBodies[0].querySelectorAll('tr').forEach(tr => {
const td = tr.cells[clientCol];
if (td) { 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); });
}
function gotoClientRow(query) {
if (!query) return;
const table = document.querySelector('#qdb-table');
if (!table) return;
let clientCol = -1;
const thead = table.tHead;
const rows = thead ? thead.rows : [];
if (rows.length >= 2) {
const cells2 = Array.from(rows[1].cells).map(c => c.textContent.trim().toLowerCase());
clientCol = cells2.findIndex(t => t === 'client code' || t === 'client_code');
}
if (clientCol < 0 && rows.length >= 1) {
const cells1 = Array.from(rows[0].cells).map(c => (c.getAttribute('data-id') || c.textContent).trim().toLowerCase());
clientCol = cells1.findIndex(t => t === 'client code' || t === 'client_code');
}
if (clientCol < 0) return;
const tryCols = [clientCol, clientCol + 1, Math.max(0, clientCol - 1)];
const bodyRows = Array.from(table.tBodies[0].rows);
let target = null;
outer:
for (const tr of bodyRows) {
for (const col of tryCols) {
const txt = (tr.cells[col]?.textContent || '').trim();
if (txt && (txt === query || txt.toLowerCase().includes(query.toLowerCase()))) {
target = tr; break outer;
}
}
}
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('row-highlight');
setTimeout(() => target.classList.remove('row-highlight'), 1800);
} else {
alert('No matching client found.');
}
}
/* ---------- 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);
$('#changePwForm').addEventListener('submit', handleForcedPwChange);
$('#volChangePwForm').addEventListener('submit', handleVoluntaryPwChange);
$('#vcpCancel').addEventListener('click', hideVoluntaryPwChange);
$('#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 u = localStorage.getItem('qdb_user');
const r = localStorage.getItem('qdb_role');
if (t && u) setLoggedIn(u, t, r);
</script>
</body>
</html>

25
lib/encrypted_payload.php Normal file
View File

@ -0,0 +1,25 @@
<?php
/**
* Application-layer encryption for sensitive mobile API payloads.
* AES-256-CBC (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
*/
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
$key = hkdf_session_key_from_token($tokenHex);
return [
'encrypted' => true,
'payload' => base64_encode(aes256_cbc_encrypt_bytes($plainJson, $key)),
];
}
function qdb_decrypt_sensitive_envelope(array $envelope, string $tokenHex): string {
if (empty($envelope['encrypted']) || !isset($envelope['payload']) || !is_string($envelope['payload'])) {
throw new InvalidArgumentException('Invalid encrypted envelope');
}
$raw = base64_decode($envelope['payload'], true);
if ($raw === false) {
throw new InvalidArgumentException('Invalid base64 payload');
}
$key = hkdf_session_key_from_token($tokenHex);
return aes256_cbc_decrypt_bytes($raw, $key);
}

View File

@ -1,102 +0,0 @@
<?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

@ -1,146 +0,0 @@
<?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

@ -1,178 +0,0 @@
<?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

@ -1,124 +0,0 @@
<?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,
co.username AS coachUsername,
sv.username AS supervisorUsername,
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
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
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

@ -1,61 +0,0 @@
<?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

@ -1,103 +0,0 @@
<?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

@ -1,211 +0,0 @@
<?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() ?: '';
}
}

View File

@ -1,5 +1,7 @@
<?php
require_once __DIR__ . '/encrypted_payload.php';
function json_success(mixed $data, int $status = 200): never {
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
@ -22,3 +24,26 @@ function read_json_body(): array {
}
return $data;
}
function read_encrypted_json_body(string $tokenHex): array {
$outer = read_json_body();
if (empty($outer['encrypted'])) {
json_error('ENCRYPTION_REQUIRED', 'Sensitive requests must send an encrypted payload', 400);
}
try {
$plain = qdb_decrypt_sensitive_envelope($outer, $tokenHex);
} catch (Throwable $e) {
error_log('decrypt body failed: ' . $e->getMessage());
json_error('DECRYPT_FAILED', 'Could not decrypt request payload', 400);
}
$data = json_decode($plain, true);
if (!is_array($data)) {
json_error('INVALID_BODY', 'Decrypted body must be valid JSON object', 400);
}
return $data;
}
function json_success_sensitive(mixed $data, string $tokenHex, int $status = 200): never {
$plain = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
json_success(qdb_sensitive_envelope($plain, $tokenHex), $status);
}

View File

@ -1,85 +0,0 @@
<?php
// /var/www/html/login.php
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$raw = file_get_contents("php://input");
$data = json_decode($raw, true) ?: [];
$username = trim($data['username'] ?? '');
$password = (string)($data['password'] ?? '');
if ($username === '' || $password === '') {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Username/password missing"]);
exit;
}
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);
$pdo = null;
qdb_discard($tmpDb, $lockFp);
if (!$user) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Invalid credentials"]);
exit;
}
if (!password_verify($password, $user['passwordHash'])) {
http_response_code(401);
echo json_encode(["success" => false, "message" => "Invalid credentials"]);
exit;
}
if (($user['role'] ?? '') === 'coach') {
http_response_code(403);
echo json_encode([
"success" => false,
"message" => "Coach accounts can only sign in through the mobile app.",
]);
exit;
}
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,
]);
echo json_encode([
"success" => true,
"user" => $username,
"role" => $user['role'],
"must_change_password" => true,
"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"]);
}

View File

@ -1,236 +0,0 @@
<?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_web();
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"]);

View File

@ -1,104 +0,0 @@
{
"client_code": "Client code",
"questionnaire_1_demographic_information": "Questionnaire status",
"questionnaire_1_demographic_information-coach_code": "Coach Code",
"questionnaire_1_demographic_information-consent_instruction": "Obtain client consent (separate document)",
"questionnaire_1_demographic_information-no_consent_entered": "You have indicated that the client has not signed the consent form.",
"questionnaire_1_demographic_information-accommodation": "Accommodation",
"questionnaire_1_demographic_information-other_accommodation": "Other accommodation (name)",
"questionnaire_1_demographic_information-client_code_entry_question": "Please enter client code and coach code.",
"questionnaire_1_demographic_information-age": "Age: How old are you?",
"questionnaire_1_demographic_information-gender": "Gender",
"questionnaire_1_demographic_information-country_of_origin": "Country of origin: where are you from?",
"questionnaire_1_demographic_information-departure_country": "When did you leave your country of origin?",
"questionnaire_1_demographic_information-since_in_germany": "Since when have you been in Germany?",
"questionnaire_1_demographic_information-living_situation": "How are you living here?",
"questionnaire_1_demographic_information-number_family_members": "How many family members are you living with?",
"questionnaire_1_demographic_information-languages_spoken": "Which languages do you speak?",
"questionnaire_1_demographic_information-german_skills": "How would you rate your German language skills?",
"questionnaire_1_demographic_information-school_years_total": "How many years did you attend school?",
"questionnaire_1_demographic_information-school_years_origin": "How many years did you attend school? (in your home country)",
"questionnaire_1_demographic_information-school_years_transit": "How many years did you attend school? (while in transit)",
"questionnaire_1_demographic_information-school_years_germany": "How many years did you attend school? (in Germany)",
"questionnaire_1_demographic_information-vocational_training": "Have you completed vocational training?",
"questionnaire_1_demographic_information-provisional_accommodation_since": "Since when have you been in provisional accommodation?",
"questionnaire_2_rhs": "Questionnaire status",
"questionnaire_2_rhs-coach_code": "Coach Code",
"questionnaire_2_rhs-glass_explanation": "The response options are shown in words and as a picture of a glass container: if something does not apply at all, the glass is empty; if it applies somewhat or fairly, the glass fills up; if it applies extremely, the glass is full.",
"questionnaire_2_rhs-q1_symptom": "1. Muscle, bone, or joint pain",
"questionnaire_2_rhs-q2_symptom": "2. Feeling unhappy, sad, or depressed most of the time",
"questionnaire_2_rhs-q3_symptom": "3. Overthinking or worrying too much",
"questionnaire_2_rhs-q4_symptom": "4. Feeling helpless",
"questionnaire_2_rhs-q5_symptom": "5. Being easily startled for no reason",
"questionnaire_2_rhs-q6_symptom": "6. Fatigue, dizziness, or feeling weak",
"questionnaire_2_rhs-q7_symptom": "7. Feeling nervous or insecure",
"questionnaire_2_rhs-q8_symptom": "8. Feeling restless, unable to sit still",
"questionnaire_2_rhs-q9_symptom": "9. Feeling like crying easily",
"questionnaire_2_rhs-q10_reexperience_trauma": "10. ... re-experiencing the trauma; behaving or feeling as if it were happening again?",
"questionnaire_2_rhs-q11_physical_reaction": "11. ... experiencing physical reactions (e.g., sweating, rapid heartbeat) when reminded of the trauma?",
"questionnaire_2_rhs-q12_emotional_numbness": "12. ... feeling emotionally numb (e.g., feeling sad but unable to cry, unable to feel loving emotions)?",
"questionnaire_2_rhs-q13_easily_startled": "13. ... being more easily startled (e.g., when someone approaches from behind)?",
"questionnaire_2_rhs-q14_intro": "14. Thinking about your life in the past four weeks, to what extent do you feel that you:",
"questionnaire_2_rhs-pain_rating_instruction": "Choose the number (010) that best describes how much suffering the respondent experienced in the past week, including today.",
"questionnaire_2_rhs-violence_question_1": "Have you ever been injured by others so seriously that you needed medical attention? (doctor, hospital)",
"questionnaire_2_rhs-times_happend": "Has this ... happened?",
"questionnaire_2_rhs-age_at_incident": "At what age: at ... years?",
"questionnaire_2_rhs-conflict_since_arrival": "Since arriving in Germany, have you ever been involved in violent conflicts (physical fights, physical or psychological conflicts)?",
"questionnaire_2_rhs-times_happend2": "Has this ... happened?",
"questionnaire_2_rhs-age_at_incident2": "At what age: at ... years?",
"questionnaire_2_rhs-asylum_procedure_since": "Since when has your asylum procedure been ongoing?",
"questionnaire_3_integration_index": "Questionnaire status",
"questionnaire_3_integration_index-coach_code": "Coach Code",
"questionnaire_3_integration_index-feeling_connected_to_germany_question": "How connected do you feel to Germany (how well have you settled in Germany)?",
"questionnaire_3_integration_index-understanding_political_issues": "How well do you understand the most important political issues in Germany?",
"questionnaire_3_integration_index-unexpected_expense_question": "Imagine you had to make an unexpected but necessary payment. Which of the following amounts could you pay or afford (if you had free access to money)?",
"questionnaire_3_integration_index-dining_with_germans_question": "How often have you dined (e.g. dinner) with Germans in the past 12 months?",
"questionnaire_3_integration_index-reading_german_articles_question": "How well can you read simple newspaper articles in German about familiar topics (health, home, family, etc.) and understand the main points?",
"questionnaire_3_integration_index-visiting_doctor_question": "How easy or difficult would it be for you to see a doctor in this country? (making an appointment, etc. - not: 'you can't get an appointment', 'I would never go to a doctor here')",
"questionnaire_3_integration_index-feeling_as_outsider_question": "How often do you feel like an outsider in Germany?",
"questionnaire_3_integration_index-recent_occupation_question": "How have you been occupied in the last four weeks? (Please select only one answer)",
"questionnaire_3_integration_index-discussing_politics_question": "Since arriving in Germany, how often have you discussed important political topics in Germany with other people?",
"questionnaire_3_integration_index-contact_with_germans_question": "With how many Germans have you exchanged messages (phone, messenger chat (WhatsApp, Facebook...), or SMS) in the last 4 weeks?",
"questionnaire_3_integration_index-speaking_german_opinion_question": "How well can you participate in a conversation in German on familiar topics (children, family, school, home, etc.) and express your opinion?",
"questionnaire_3_integration_index-job_search_question": "How easy or difficult would it be for you to search for and find a job in this country (even if you may not be allowed to work: what would be your assumption if there were no restrictions)?",
"questionnaire_4_consultation_results": "Questionnaire status",
"questionnaire_4_consultation_results-coach_code": "Coach Code",
"questionnaire_4_consultation_results-date_consultation_health_interview_result": "Date of counseling interview (health interview result green/yellow/red)",
"questionnaire_4_consultation_results-consultation_decision": "Counseling decision (<b><font color='#9E9E9E'>0</font></b>)",
"questionnaire_4_consultation_results-consent_conversation_in_6_months": "Consent for conversation in 6 months:",
"questionnaire_4_consultation_results-participation_in_coaching": "Participation In Coaching",
"questionnaire_4_consultation_results-consent_coaching_given": "Consent for coaching is given",
"questionnaire_4_consultation_results-decision_after_reflection_period": "Decision on .............. (date) after reflection period",
"questionnaire_4_consultation_results-professional_referral": "Professional Referral",
"questionnaire_4_consultation_results-confidentiality_agreement": "Confidentiality Agreement",
"questionnaire_4_consultation_results-health_insurance_card": "Health Insurance Card",
"questionnaire_5_final_interview": "Final Interview",
"questionnaire_5_final_interview-coach_code": "Coach Code",
"questionnaire_5_final_interview-consent_followup_6_months": "Consent for conversation in 6 months",
"questionnaire_5_final_interview-date_final_interview": "Date of final interview",
"questionnaire_5_final_interview-amount_nat_appointments": "Number of NAT appointments (after the counseling interview)",
"questionnaire_5_final_interview-amount_session_flowers": "Number of storytelling sessions with flowers",
"questionnaire_5_final_interview-amount_session_stones": "Number of storytelling sessions with stones",
"questionnaire_5_final_interview-termination_nat_coaching": "Termination of NAT coaching",
"questionnaire_5_final_interview-client_canceled_NAT": "Client discontinued NAT",
"questionnaire_6_follow_up_survey": "Questionnaire status",
"questionnaire_6_follow_up_survey-coach_code": "Coach Code",
"questionnaire_6_follow_up_survey-follow_up": "Follow-up survey",
"questionnaire_6_follow_up_survey-special_burden_question": "Was there any special burden?",
"questionnaire_6_follow_up_survey-glass_explanation": "The response options are shown in words and as a picture of a glass container: if something does not apply at all, the glass is empty; if it applies somewhat or fairly, the glass fills up; if it applies extremely, the glass is full.",
"questionnaire_6_follow_up_survey-how_strong_past_month": "How strongly did you experience the following in the past month...",
"questionnaire_6_follow_up_survey-q14_intro": "14. Thinking about your life in the past four weeks, to what extent do you feel that you:",
"questionnaire_6_follow_up_survey-pain_rating_instruction": "Choose the number (010) that best describes how much suffering the respondent experienced in the past week, including today.",
"questionnaire_6_follow_up_survey-feeling_connected_to_germany_question": "How connected do you feel to Germany (how well have you settled in Germany)?",
"questionnaire_6_follow_up_survey-understanding_political_issues": "How well do you understand the most important political issues in Germany?",
"questionnaire_6_follow_up_survey-unexpected_expense_question": "Imagine you had to make an unexpected but necessary payment. Which of the following amounts could you pay or afford (if you had free access to money)?",
"questionnaire_6_follow_up_survey-dining_with_germans_question": "How often have you dined (e.g. dinner) with Germans in the past 12 months?",
"questionnaire_6_follow_up_survey-reading_german_articles_question": "How well can you read simple newspaper articles in German about familiar topics (health, home, family, etc.) and understand the main points?",
"questionnaire_6_follow_up_survey-visiting_doctor_question": "How easy or difficult would it be for you to see a doctor in this country? (making an appointment, etc. - not: 'you can't get an appointment', 'I would never go to a doctor here')",
"questionnaire_6_follow_up_survey-feeling_as_outsider_question": "How often do you feel like an outsider in Germany?",
"questionnaire_6_follow_up_survey-recent_occupation_question": "How have you been occupied in the last four weeks? (Please select only one answer)",
"questionnaire_6_follow_up_survey-discussing_politics_question": "Since arriving in Germany, how often have you discussed important political topics in Germany with other people?",
"questionnaire_6_follow_up_survey-contact_with_germans_question": "With how many Germans have you exchanged messages (phone, messenger chat (WhatsApp, Facebook...), or SMS) in the last 4 weeks?",
"questionnaire_6_follow_up_survey-speaking_german_opinion_question": "How well can you participate in a conversation in German on familiar topics (children, family, school, home, etc.) and express your opinion?",
"questionnaire_6_follow_up_survey-job_search_question": "How easy or difficult would it be for you to search for and find a job in this country (even if you may not be allowed to work: what would be your assumption if there were no restrictions)?"
}

View File

@ -1,176 +0,0 @@
<?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,24 +0,0 @@
{"token":"e27b08a83d8d034746ad3f860758aa20e3c302deef5da0df29a6ab93f71dda90","created":1774517813,"exp":1777109813}
{"token":"4215cb8aa644120fce31c8bd9ff3cb3e100b6ae2f19c83bba9e1c6a87e7f4e99","created":1775642692,"exp":1778234692,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"0441863e59bbc0bef45c1377ae31a7155c249cf298981dad3aaa7d9389635d95","created":1775643020,"exp":1778235020,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"d9813ed055fd974e92c92ffd2b9c64b86a572f4dfe0a075f6e68e4d057ae9629","created":1775643419,"exp":1778235419,"role":"coach","entityID":"f956a1c2239849b8924e9cf98da9c3da","userID":"3abc03d2bacc8e48fe62b6afc382d3a3"}
{"token":"b2277c780fb2c8ed218173c76bd7f36dce48bfb284862fdd04732fe84a9d3437","created":1775643440,"exp":1778235440,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"ac2d9596f485f6f15051f267299c24aac60bd743f020c60f250d5086b1ae6dba","created":1775643458,"exp":1778235458,"role":"supervisor","entityID":"d4538fc39664f037eb8b0d4ceb8ec07e","userID":"9e23fa6f1ed02bef3a7405d15e866bb9"}
{"token":"3ae1ea99a01abedd3e2dfe6e2a4e8095a127353bf738ffb7baa0ca14b49b3ee6","created":1775659764,"exp":1778251764,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"324478b89a99a8b6987131b26d729364ef7fa7cadb51634fae160b12b052bd49","created":1775661148,"exp":1778253148,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"40032db80223b526499a7553ac4b58ecb0276b4aac8023444de1c674c07cc394","created":1775740664,"exp":1778332664,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"70417759cda569e3c11b6a443431e536d7eda73268e338ab40f7500d056083bb","created":1775741768,"exp":1778333768,"role":"supervisor","entityID":"d4538fc39664f037eb8b0d4ceb8ec07e","userID":"9e23fa6f1ed02bef3a7405d15e866bb9"}
{"token":"f067dd4ca164b0fa66a87b299308390da0913da1b16cddad73c1b322272379b6","created":1775741791,"exp":1778333791,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"3b381d3013a68896662cad27196e77e4dd3dd0117a9ddf08922dbdff807d4fb8","created":1775741807,"exp":1778333807,"role":"supervisor","entityID":"d4538fc39664f037eb8b0d4ceb8ec07e","userID":"9e23fa6f1ed02bef3a7405d15e866bb9"}
{"token":"33cf888d8a443dd3055650a8962293e7251620d9a1a8a64a6a224611bc41d3b8","created":1775744411,"exp":1778336411,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"22f588244d7635bc6faf329a6291e91b3dd1ca301e2f2ec084a921e18a757ca4","created":1775744481,"exp":1778336481,"role":"admin","entityID":"3c7c7a1f91980d255c0adcbd1efd4dd7","userID":"4c8b919c5f102c68b2a255b70d541c74"}
{"token":"26c6ef3ebfbfd8f267f96a60bc4913504ade4b3edd0404668a2cf7af0dcb99e9","created":1775745540,"exp":1775746140,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847","temp":true}
{"token":"fadf7a06ead066c607d5714546b6f4827bd21b3a4542e68386fb34ace4bcaa53","created":1775745548,"exp":1778337548,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"9fcea9c8d98fa09274dbbf6741bea50ab174a01b9b5d26138f933adf1f29957c","created":1775745732,"exp":1775746332,"role":"admin","entityID":"2069fffc7091928c70826b6498eccd7c","userID":"5fab912e0108e6b872b02c5d755cc893","temp":true}
{"token":"20fbfb3a6f248a24ab33769a5e5de06732f402e0c9b9f709d17cc3b442c3b1ba","created":1775745738,"exp":1778337738,"role":"admin","entityID":"2069fffc7091928c70826b6498eccd7c","userID":"5fab912e0108e6b872b02c5d755cc893"}
{"token":"e88d0746883123156433e55a5e07044e253f3429472df78e5d61f43c2c40e760","created":1775745809,"exp":1775746409,"role":"supervisor","entityID":"0032669cbb342c7c9937e48848e916b2","userID":"766621a2fce45e977f999926d9196e3d","temp":true}
{"token":"4cc8d74f0fe3d655e16c337612d02a68208620169c6d7ca587a25fddce61c004","created":1775745814,"exp":1778337814,"role":"supervisor","entityID":"0032669cbb342c7c9937e48848e916b2","userID":"766621a2fce45e977f999926d9196e3d"}
{"token":"2f5d7b59ccb55b21d4a5b7d7e03c9966df5c1377f447098ab398e9b789a6a188","created":1775746162,"exp":1778338162,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"f1f2ba5d1a6e898688658006c8c42c0f6cb8a6e214f08d016cfc1a2a72af1e8e","created":1775747842,"exp":1778339842,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"02ded9879607050fc2531d8fb9265faadcc4c0a6ffced7a82eb919a0354f9c9c","created":1775804416,"exp":1778396416,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}
{"token":"4923c95ed744e8eee5d4f215b16c2a1141ec49bca7f616651f124865e06e4e1b","created":1775835537,"exp":1778427537,"role":"admin","entityID":"f7f8ab5e6f32eb469e5219c5561a1d71","userID":"d64e5796774a414c8159dcb786cd5847"}

View File

@ -1,349 +0,0 @@
{
"client_code_entry_question": "Please enter client code and coach code.",
"all_fields_between_0_and_15": "Please enter a number between 0 and 15 in all fields!",
"fill_both_fields": "Please fill in both fields!",
"please_answer_all": "Please answer all questions!",
"select_accommodation": "Please select an accommodation!",
"enter_coach_code": "Please enter your coach code to continue!",
"enter_country": "Please enter a country to continue!",
"only_letters_allowed": "Please enter letters only!",
"enter_valid_number": "Please enter a valid number!",
"enter_valid_year": "Please enter a valid year!",
"enter_text_to_continue": "Please enter text to continue!",
"select_at_least_one_language": "Please select at least one language!",
"select_country": "Please select a country",
"select_gender": "Please select a gender!",
"select_one_answer": "Please select one answer!",
"select_one_option": "Please select one option!",
"coach_code": "Coach Code",
"client_code": "Client Code",
"select_month": "Select month!",
"select_year": "Select year!",
"choose_answer": "Choose answer",
"choose_option": "Choose option!",
"consent_not_signed": "Consent not signed",
"consent_signed": "Consent signed",
"error_file": "Error processing file!",
"no_school_attended": "I did not attend school!",
"once": "once",
"year_after_2000": "The year must be after 2000!",
"year_after_departure": "The year must be after leaving the country of origin!",
"year_max": "The year must be less than or equal to $MAX_VALUE_YEAR!",
"data_final_warning": "<b><font color='#FF0000'>Important:</font></b> The data cannot be changed or edited after completion!",
"multiple_times": "multiple times",
"more_than_15_years": "more than 15 years",
"no": "No",
"no_answer": "No answer",
"other_country": "Other country",
"value_must_be_less_equal_max": "The value must be less than or equal to $MAX_VALUE_AGE!",
"value_between_1_and_15": "The value must be between 1 and 15!",
"invalid_month": "Invalid month!",
"invalid_year": "Invalid year!",
"yes": "Yes",
"next": "Next",
"previous": "Back",
"finish": "Complete data entry",
"thank_you_participation": "Thank you for participating in this survey.",
"response_recorded": "Your response has been recorded.",
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December",
"consent_instruction": "Obtain client consent (separate document)",
"no_consent_entered": "You have indicated that the client has not signed the consent form.",
"no_consent_note": "This is important information for the evaluation of 'BW schützt!'.",
"coach_code_request": "Please enter your coach code and the name of the accommodation below. Then upload the data as usual.",
"other_accommodation": "Other accommodation (name)",
"age": "Age: How old are you?",
"gender": "Gender",
"gender_male": "Male",
"gender_female": "Female",
"gender_diverse": "Diverse",
"country_of_origin": "Country of origin: where are you from?",
"country_text_entry": "Country of origin (text entry)",
"departure_country": "When did you leave your country of origin?",
"year": "Year",
"since_in_germany": "Since when have you been in Germany?",
"living_situation": "How are you living here?",
"alone": "Alone",
"with_family": "With family members",
"number_family_members": "How many family members are you living with?",
"number_label": "Number of family members",
"languages_spoken": "Which languages do you speak?",
"language_albanian": "Albanian",
"language_pashto": "Pashto",
"language_russian": "Russian",
"language_serbo": "Serbo-Croatian",
"language_somali": "Somali",
"language_tamil": "Tamil",
"language_tigrinya": "Tigrinya",
"language_turkish": "Turkish",
"language_ukrainian": "Ukrainian",
"language_urdu": "Urdu",
"language_other": "Other",
"language_arabic": "Arabic",
"language_dari_farsi": "Dari/Farsi",
"language_chinese": "Chinese",
"language_english": "English",
"language_macedonian": "Macedonian",
"language_kurmanji": "Kurdish-Kurmanji",
"language_hindi": "Hindi",
"language_french": "French",
"german_skills": "How would you rate your German language skills?",
"skill_none": "None",
"skill_basic": "Basic knowledge",
"skill_a1": "A1",
"skill_a2": "A2",
"skill_b1": "B1",
"skill_b2": "B2",
"skill_c1": "C1",
"skill_c2": "C2",
"school_years_origin": "How many years did you attend school? (in your home country)",
"school_years_transit": "How many years did you attend school? (while in transit)",
"school_years_germany": "How many years did you attend school? (in Germany)",
"label_years": "Years",
"vocational_training": "Have you completed vocational training?",
"answer_no": "No",
"answer_started": "Started",
"answer_completed": "Completed",
"provisional_accommodation_since": "Since when have you been in provisional accommodation?",
"asylum_procedure_since": "Since when has your asylum procedure been ongoing?",
"accommodation": "Accommodation",
"steinstrasse": "Steinstraße",
"doerfle": "Dörfle",
"zoll_emmishofer": "Zoll (Emmishofer Straße)",
"other": "Other",
"school_years_total": "How many years did you attend school?",
"glass_explanation": "The response options are shown in words and as a picture of a glass container: if something does not apply at all, the glass is empty; if it applies somewhat or fairly, the glass fills up; if it applies extremely, the glass is full.",
"symptoms": "Symptoms",
"never_glass": "Never",
"little_glass": "A little",
"moderate_glass": "Moderately",
"much_glass": "Much",
"extreme_glass": "Extremely",
"q1_symptom": "1. Muscle, bone, or joint pain",
"q2_symptom": "2. Feeling unhappy, sad, or depressed most of the time",
"q3_symptom": "3. Overthinking or worrying too much",
"q4_symptom": "4. Feeling helpless",
"q5_symptom": "5. Being easily startled for no reason",
"q6_symptom": "6. Fatigue, dizziness, or feeling weak",
"q7_symptom": "7. Feeling nervous or insecure",
"q8_symptom": "8. Feeling restless, unable to sit still",
"q9_symptom": "9. Feeling like crying easily",
"how_strong_past_month": "How strongly did you experience the following in the past month...",
"q10_reexperience_trauma": "10. ... re-experiencing the trauma; behaving or feeling as if it were happening again?",
"q11_physical_reaction": "11. ... experiencing physical reactions (e.g., sweating, rapid heartbeat) when reminded of the trauma?",
"q12_emotional_numbness": "12. ... feeling emotionally numb (e.g., feeling sad but unable to cry, unable to feel loving emotions)?",
"q13_easily_startled": "13. ... being more easily startled (e.g., when someone approaches from behind)?",
"instruction_listen_statements": "I will now read 5 statements to you. Please listen carefully and then tell me which one best applies to you:",
"q14_intro": "14. Thinking about your life in the past four weeks, to what extent do you feel that you:",
"q14_option_1": "Are able to handle everything that comes your way",
"q14_option_2": "Are able to handle most things that come your way",
"q14_option_3": "Are able to handle some things but not others",
"q14_option_4": "Are unable to handle most things",
"q14_option_5": "Are unable to handle anything",
"pain_rating_instruction": "Choose the number (010) that best describes how much suffering the respondent experienced in the past week, including today.",
"violence_intro": "Finally, I have 2 questions about experiences of violence:",
"violence_question_1": "Have you ever been injured by others so seriously that you needed medical attention? (doctor, hospital)",
"times_happend": "Has this ... happened?",
"age_at_incident": "At what age: at ... years?",
"times_happend2": "Has this ... happened?",
"age_at_incident2": "At what age: at ... years?",
"conflict_since_arrival": "Since arriving in Germany, have you ever been involved in violent conflicts (physical fights, physical or psychological conflicts)?",
"finish_data_entry": "When you have finished entering the data, click \"Save\".",
"feeling_connected_to_germany_question": "How connected do you feel to Germany (how well have you settled in Germany)?",
"intro_life_in_germany": "Now we would like to ask you some questions about your life in Germany:",
"not_connected_at_all": "not connected at all",
"somewhat_loose": "somewhat loose",
"moderately_connected": "moderately connected",
"quite_connected": "quite connected",
"very_connected": "very connected",
"understanding_political_issues": "How well do you understand the most important political issues in Germany?",
"very_good": "very good",
"good": "good",
"fairly_good": "fairly good",
"somewhat": "somewhat",
"not_at_all": "not at all",
"unexpected_expense_question": "Imagine you had to make an unexpected but necessary payment. Which of the following amounts could you pay or afford (if you had free access to money)?",
"expense_50": "up to €50",
"expense_100": "€100",
"expense_300": "€300",
"expense_500": "€500",
"expense_800": "€800",
"dining_with_germans_question": "How often have you dined (e.g. dinner) with Germans in the past 12 months?",
"never": "never",
"once_a_year": "once a year",
"once_a_month": "once a month",
"once_a_week": "once a week",
"almost_every_day": "almost every day",
"reading_german_articles_question": "How well can you read simple newspaper articles in German about familiar topics (health, home, family, etc.) and understand the main points?",
"not_good": "not well",
"visiting_doctor_question": "How easy or difficult would it be for you to see a doctor in this country? (making an appointment, etc. - not: 'you can't get an appointment', 'I would never go to a doctor here')",
"very_difficult": "very difficult",
"rather_difficult": "rather difficult",
"neither_nor": "neither difficult nor easy",
"rather_easy": "rather easy",
"very_easy": "very easy",
"feeling_as_outsider_question": "How often do you feel like an outsider in Germany?",
"rarely": "rarely",
"sometimes": "sometimes",
"often": "often",
"always": "always",
"recent_occupation_question": "How have you been occupied in the last four weeks? (Please select only one answer)",
"employed": "in paid employment, even if only temporarily (employed, self-employed, minor jobs)",
"education": "in education (school, vocational training, university)",
"internship": "internship",
"unemployed_searching": "unemployed and actively looking for work",
"unemployed_not_searching": "unemployed and not actively looking for work",
"sick_or_disabled": "ill or unable to work",
"unpaid_housework": "in unpaid housework, childcare / caring for others",
"other_activity": "Other (e.g., language course)",
"discussing_politics_question": "Since arriving in Germany, how often have you discussed important political topics in Germany with other people?",
"contact_with_germans_question": "With how many Germans have you exchanged messages (phone, messenger chat (WhatsApp, Facebook...), or SMS) in the last 4 weeks?",
"zero": "0",
"one_to_three": "1 to 3",
"three_to_six": "3 to 6",
"seven_to_fourteen": "7 to 14",
"fifteen_or_more": "15 or more",
"speaking_german_opinion_question": "How well can you participate in a conversation in German on familiar topics (children, family, school, home, etc.) and express your opinion?",
"moderately_good": "moderately good",
"job_search_question": "How easy or difficult would it be for you to search for and find a job in this country (even if you may not be allowed to work: what would be your assumption if there were no restrictions)?",
"integration_index": "Integration Index: %d",
"consent_followup_6_months": "Consent for conversation in 6 months",
"day": "Day",
"month": "Month",
"select_day": "Please select a day.",
"select_month": "Please select a month.",
"select_year": "Please select a year.",
"date_final_interview": "Date of final interview",
"amount_nat_appointments": "Number of NAT appointments (after the counseling interview)",
"amount_session_flowers": "Number of storytelling sessions with flowers",
"amount_session_stones": "Number of storytelling sessions with stones",
"termination_nat_coaching": "Termination of NAT coaching",
"termination_nat_regular": "Regular (mutual decision to terminate after discussing all important events)",
"termination_nat_referral": "Referral to a specialized professional (doctor, psychologist, ...)",
"termination_nat_dropout": "Client discontinued NAT",
"follow_up": "Follow-up survey",
"follow_up_completed": "was successfully conducted",
"follow_up_failed_contact": "could not be conducted: repeated failed contact attempts",
"follow_up_consent_withdrawn": "could not be conducted: consent withdrawn",
"special_burden_question": "Was there any special burden?",
"resilience_fully_capable": "Able to handle everything that comes your way",
"resilience_mostly_capable": "Able to handle most things that come your way",
"resilience_partially_capable": "Able to handle some things but not others",
"resilience_mostly_not_capable": "Unable to handle most things",
"resilience_not_capable": "Unable to handle anything",
"intro_resilience_questions": "I will now read 5 statements to you. Please listen carefully and then tell me which statement best applies to you:",
"resilience_reflection_prompt": "Thinking about your life in the past four weeks, to what extent do you feel that you:",
"resilience_some_capable_some_not": "Able to handle most things but unable to handle some others",
"follow_up_survey_score": "The RHS total score is: %d",
"select_one_answer_per_row": "Please select one answer per row!",
"no_next_question_defined": "No forwarding page defined",
"date_consultation_health_interview_result": "Date of counseling interview (health interview result green/yellow/red)",
"consultation_decision": "Counseling decision (RHS_POINTS)",
"consent_conversation_in_6_months": "Consent for conversation in 6 months:",
"participation_in_coaching": "Participation in coaching",
"decision_after_reflection_period": "Decision on .............. (date) after reflection period",
"participation_coaching": "Coaching participation",
"consent_coaching_given": "Consent for coaching is given",
"consent_form_must_be_available": "Consent form must be available!",
"professional_referral": "Professional referral",
"confidentiality_agreement": "Confidentiality agreement",
"health_insurance_card": "Health insurance card",
"green": "green",
"yellow": "yellow",
"red": "red",
"time_to_think_about_it": "Reflection period",
"consent_yes": "yes, consent is given",
"consent_no": "no, consent is not given",
"available_yes": "yes, available",
"available_no": "no, not available",
"client_canceled_NAT": "Client discontinued NAT",
"after_meeting": "after session",
"without_giving_reasons": "without giving reasons",
"with_reasons_given": "with reasons given",
"select_at_least_one_answer": "Please select at least one answer.",
"select_at_least_minimum": "Please select at least %d answers.",
"demographic_information": "Demographic information",
"rhs": "RHS",
"integration_index_label": "Integration index",
"consultation_results": "Counseling results",
"final_interview": "Final interview",
"follow_up_survey": "Follow-up survey",
"questionnaire_title": "Questionnaires",
"client_code_exists": "This client code already exists.",
"load": "Load",
"date_after": "The date must be after",
"date_before": "The date must be before",
"lay": "it.",
"choose_more_elements": "More elements must be selected.",
"please_client_code": "Please enter client code",
"no_profile": "This client is not yet part of the database",
"questionnaires_finished": "All questionnaires have been completed for this client!",
"edit": "Edit",
"save": "Save",
"upload": "Upload",
"download": "Download",
"database": "Database",
"clients": "Clients",
"client": "Client",
"client_code_label": "Client Code",
"questionnaires": "Questionnaires",
"questionnaire": "Questionnaire",
"questionnaire_id": "Questionnaire ID",
"status": "Status",
"header_label": "Header",
"id": "ID",
"value": "Value",
"no_clients": "No clients available.",
"no_questionnaires": "No questionnaires available.",
"no_questions": "No questions available.",
"question": "Question",
"answer": "Answer",
"download_header": "Download header",
"back": "Back",
"export_success_downloads_headers": "Export successful: Downloads/ClientHeaders.xlsx",
"export_failed": "Export failed.",
"error_generic": "Error",
"not_done": "Not Done",
"none": "None",
"done": "Done",
"locked": "Locked",
"start": "Start",
"points": "Points",
"saved_pdf_csv": "PDF and CSV were saved in the \"Downloads\" folder.",
"no_pdf_viewer": "No PDF viewer installed.",
"save_error": "Error while saving: {message}",
"login_required_title": "Login required",
"username_hint": "Username",
"password_hint": "Password",
"login_btn": "Login",
"exit_btn": "Exit",
"please_username_password": "Please enter username and password.",
"download_failed_no_local_db": "Download failed no local database available",
"download_failed_use_offline": "Download failed working offline with existing database",
"login_failed_with_reason": "Login failed: {reason}",
"no_header_template_found": "No header template found",
"login_required": "Please log in first",
"session_label": "Session",
"session_dash": "Session: —",
"hours_short": "h",
"minutes_short": "min",
"online": "Online",
"offline": "Offline",
"open_client_via_load": "Please open the client via \"Load\".",
"database_clients_title": "Database Clients",
"no_clients_available": "No clients available.",
"headers": "Headers",
"no_questions_available": "No questions available.",
"questionnaire_missing_options": "This questionnaire is incomplete (missing answer options). Please contact your administrator.",
"view_missing": "Missing view: %s"
}

View File

@ -1,356 +0,0 @@
<?php
// /var/www/html/uploadDeltaTest5.php
require_once __DIR__ . '/db_init.php';
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
$LOG_FILE = __DIR__ . '/uploadDelta.log';
function log_msg($msg) {
global $LOG_FILE;
error_log(date('[Y-m-d H:i:s] ') . $msg . PHP_EOL, 3, $LOG_FILE);
}
header('Content-Type: application/json; charset=UTF-8');
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(["error" => "Only POST allowed"]);
exit;
}
// Token from multipart field or Authorization: Bearer
$token = $_POST['token'] ?? null;
if (!$token) {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
if (stripos($auth, 'Bearer ') === 0) {
$token = substr($auth, 7);
}
}
$tokenRec = $token ? token_get_record($token) : null;
if (!$tokenRec || !empty($tokenRec['temp'])) {
http_response_code(403);
echo json_encode(["error" => "Invalid token"]);
exit;
}
// Upload: multipart 'file' or raw body
$uploadedFilePath = null;
if (!empty($_FILES['file']) && is_uploaded_file($_FILES['file']['tmp_name'])) {
$uploadedFilePath = $_FILES['file']['tmp_name'];
} else {
$raw = file_get_contents('php://input');
if ($raw !== false && strlen($raw) > 0) {
$tmp = tempnam(sys_get_temp_dir(), 'upl_');
file_put_contents($tmp, $raw);
$uploadedFilePath = $tmp;
}
}
if (!$uploadedFilePath) {
http_response_code(400);
echo json_encode(["error" => "No file sent"]);
exit;
}
// Decrypt payload with session key derived from token via HKDF
$encData = file_get_contents($uploadedFilePath);
if ($encData === false) {
http_response_code(500);
echo json_encode(["error" => "Failed to read uploaded file"]);
exit;
}
$sessionKey = hkdf_session_key_from_token($token);
$jsonData = null;
try {
$jsonData = aes256_cbc_decrypt_bytes($encData, $sessionKey);
} catch (Throwable $e) {
$maybeJson = trim($encData);
if ($maybeJson === '' || json_decode($maybeJson, true) === null) {
log_msg("Decryption failed and not valid JSON. raw len=" . strlen($encData));
http_response_code(400);
echo json_encode(["error" => "Invalid encrypted file or not JSON"]);
exit;
}
$jsonData = $maybeJson;
}
$data = json_decode($jsonData, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(["error" => "Invalid JSON"]);
exit;
}
// RBAC: coaches can only upload for their own clients
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->beginTransaction();
if ($role === 'coach' && !empty($data['client'])) {
foreach ($data['client'] as $c) {
if (!isset($c['clientCode'])) continue;
$chk = $pdo->prepare("SELECT coachID FROM client WHERE clientCode = :cc");
$chk->execute([':cc' => $c['clientCode']]);
$existing = $chk->fetch(PDO::FETCH_ASSOC);
if ($existing && $existing['coachID'] !== $entityID) {
$pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode(["error" => "Not authorized to modify client " . $c['clientCode']]);
exit;
}
}
}
// --- Collect affected IDs ---
$questionnaireIds = [];
$clientQuestionPairs = [];
if (!empty($data['questionnaire'])) {
foreach ($data['questionnaire'] as $q) {
if (!empty($q['questionnaireID'])) $questionnaireIds[$q['questionnaireID']] = true;
}
}
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 => $_) {
[$cc, $qid] = explode('|', $pair, 2);
try {
$delAnswersByPairStmt->execute([':cc' => $cc, ':qid' => $qid]);
$delCompletedByPairStmt->execute([':cc' => $cc, ':qid' => $qid]);
log_msg("Deleted answers & completed for client='$cc' questionnaire='$qid'");
} catch (Exception $e) {
log_msg("Delete error for pair $pair: " . $e->getMessage());
}
}
if (!empty($questionnaireIds)) {
$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 => $_) {
try {
$delAOStmt->execute([':qid' => $qid]);
$delQTransStmt->execute([':qid' => $qid]);
$delQuestionsStmt->execute([':qid' => $qid]);
log_msg("Deleted questions/options/translations for questionnaireID='$qid'");
} catch (Exception $e) {
log_msg("Delete questions error for $qid: " . $e->getMessage());
}
}
}
// --- Inserts/Upserts ---
if (!empty($data['client'])) {
$stmt = $pdo->prepare(
"INSERT OR REPLACE INTO client (clientCode, coachID) VALUES (:cc, :cid)"
);
foreach ($data['client'] as $c) {
if (!isset($c['clientCode'])) continue;
$cc = $c['clientCode'] ?: 'undefined';
$cid = $c['coachID'] ?? $entityID;
$stmt->execute([':cc' => $cc, ':cid' => $cid]);
}
}
if (!empty($data['questionnaire'])) {
$stmt = $pdo->prepare(
"INSERT OR REPLACE INTO questionnaire (questionnaireID, name, version, state)
VALUES (:qid, :name, :ver, :state)"
);
foreach ($data['questionnaire'] as $q) {
if (!isset($q['questionnaireID'])) continue;
$stmt->execute([
':qid' => $q['questionnaireID'] ?: 'undefined',
':name' => $q['name'] ?? '',
':ver' => $q['version'] ?? '',
':state' => $q['state'] ?? '',
]);
}
}
if (!empty($data['question'])) {
$stmt = $pdo->prepare(
"INSERT OR IGNORE INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired)
VALUES (:qid, :qqid, :dt, :t, :oi, :ir)"
);
foreach ($data['question'] as $q) {
if (!isset($q['questionID'])) continue;
$stmt->execute([
':qid' => $q['questionID'] ?: 'undefined',
':qqid' => $q['questionnaireID'] ?? 'undefined',
':dt' => $q['defaultText'] ?? '',
':t' => $q['type'] ?? '',
':oi' => (int)($q['orderIndex'] ?? 0),
':ir' => (int)($q['isRequired'] ?? 0),
]);
}
}
if (!empty($data['answer_option'])) {
$stmt = $pdo->prepare(
"INSERT OR IGNORE INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex)
VALUES (:aoid, :qid, :dt, :pts, :oi)"
);
foreach ($data['answer_option'] as $ao) {
if (!isset($ao['answerOptionID'])) continue;
$stmt->execute([
':aoid' => $ao['answerOptionID'],
':qid' => $ao['questionID'] ?? 'undefined',
':dt' => $ao['defaultText'] ?? '',
':pts' => (int)($ao['points'] ?? 0),
':oi' => (int)($ao['orderIndex'] ?? 0),
]);
}
}
if (!empty($data['client_answer'])) {
$stmt = $pdo->prepare(
"INSERT OR REPLACE INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)"
);
foreach ($data['client_answer'] as $a) {
if (!isset($a['clientCode'], $a['questionID'])) continue;
$stmt->execute([
':cc' => $a['clientCode'] ?: 'undefined',
':qid' => $a['questionID'] ?: 'undefined',
':aoid' => $a['answerOptionID'] ?? null,
':ftv' => $a['freeTextValue'] ?? null,
':nv' => isset($a['numericValue']) ? (float)$a['numericValue'] : null,
':at' => isset($a['answeredAt']) ? (int)$a['answeredAt'] : null,
]);
}
}
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);
echo json_encode(["success" => true, "message" => "Delta applied and DB saved"]);
exit;
} catch (Throwable $inner) {
qdb_discard($tmpDb, $lockFp);
@unlink($uploadedFilePath ?? '');
log_msg("Inner exception: " . $inner->getMessage());
http_response_code(500);
error_log($inner->getMessage());
echo json_encode(["error" => "Save error"]);
exit;
}
} catch (Throwable $e) {
log_msg("Top-level exception: " . $e->getMessage());
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
exit;
}

View File

@ -1,50 +0,0 @@
{
"user01": {
"hash": "$2y$10$fcB5mBc.RDeCHyY69RScZOpFL/IjY.wFzx1DNzKI7F6kJhd9rwQfu",
"changedAt": 1760610683
},
"user02": {
"hash": "$2y$10$fGDcFLBgL3oypyjkwVna/OhQ5Be6UFH6hNGdupeNfJMASgrFK7h0K",
"changedAt": 1760611508
},
"Tmp_Daniel": {
"hash": "$2y$10$NRdP4/vkDQEM4fD70xGqdO1dgujOBATcA00Zj2aIaH0hoQV5Rp8ri",
"changedAt": 1760612940
},
"Tmp_Liliana": {
"hash": "$2y$10$4Gui323PXRnQ9Ix4WSrefOEr2HQwfbr6TNVaa7qVE2q59ZwBpnisa",
"changedAt": 1760971357
},
"Tmp_Johanna": {
"hash": "$2y$10$B4eNyer1anHYMNjpNliIW.54sK8z1cYyQEHjmHloatUnmeilchjva",
"changedAt": 1760972135
},
"Danny": {
"hash": "$2y$10$JreuWqvh1VO3fH2YhSpqveDpSSgVPUeNW91ySSzxeQ0o/OgNe1gGS",
"changedAt": 1771865346
},
"Extra1_tmp": {
"hash": "$2y$10$zUtdsv1N2Uv/7XZT4wr4lOTnJEWFF74EJp2/kgq6GHpO8dguMQify",
"changedAt": 1771865748
},
"User11": {
"hash": "$2y$10$ql0aI9C.1nzkE33Oxy8T3.dE0jF0y42fe4x8YWyqFm.SwlUBoqv22",
"changedAt": 1772001151
},
"User10": {
"hash": "$2y$10$5HoG6T6UA9CFd2q1VGLiE.3EbwNyyVR8kSuTZWxJ5run8KvHgrhhK",
"changedAt": 1772110997
},
"User2": {
"hash": "$2y$10$/KxLlL9ZdHnGYL1y4IEIfOnWL.bd2HgPVfk.n5V0Sc.3LzDwmeWl6",
"changedAt": 1772111983
},
"User3": {
"hash": "$2y$10$GMtUWbW.bczjc43dpf539OE7rFgwL08QV1qnjFlyDh5mF/Ss4atom",
"changedAt": 1772112290
},
"User7": {
"hash": "$2y$10$AfJp8go8Vt5J0.46SqYmtOPrrp1CqkWw1pmkOGI6xZ5MgqHTmwIga",
"changedAt": 1772283082
}
}

View File

@ -1,24 +0,0 @@
e27b08a83d8d034746ad3f860758aa20e3c302deef5da0df29a6ab93f71dda90
4215cb8aa644120fce31c8bd9ff3cb3e100b6ae2f19c83bba9e1c6a87e7f4e99
0441863e59bbc0bef45c1377ae31a7155c249cf298981dad3aaa7d9389635d95
d9813ed055fd974e92c92ffd2b9c64b86a572f4dfe0a075f6e68e4d057ae9629
b2277c780fb2c8ed218173c76bd7f36dce48bfb284862fdd04732fe84a9d3437
ac2d9596f485f6f15051f267299c24aac60bd743f020c60f250d5086b1ae6dba
3ae1ea99a01abedd3e2dfe6e2a4e8095a127353bf738ffb7baa0ca14b49b3ee6
324478b89a99a8b6987131b26d729364ef7fa7cadb51634fae160b12b052bd49
40032db80223b526499a7553ac4b58ecb0276b4aac8023444de1c674c07cc394
70417759cda569e3c11b6a443431e536d7eda73268e338ab40f7500d056083bb
f067dd4ca164b0fa66a87b299308390da0913da1b16cddad73c1b322272379b6
3b381d3013a68896662cad27196e77e4dd3dd0117a9ddf08922dbdff807d4fb8
33cf888d8a443dd3055650a8962293e7251620d9a1a8a64a6a224611bc41d3b8
22f588244d7635bc6faf329a6291e91b3dd1ca301e2f2ec084a921e18a757ca4
26c6ef3ebfbfd8f267f96a60bc4913504ade4b3edd0404668a2cf7af0dcb99e9
fadf7a06ead066c607d5714546b6f4827bd21b3a4542e68386fb34ace4bcaa53
9fcea9c8d98fa09274dbbf6741bea50ab174a01b9b5d26138f933adf1f29957c
20fbfb3a6f248a24ab33769a5e5de06732f402e0c9b9f709d17cc3b442c3b1ba
e88d0746883123156433e55a5e07044e253f3429472df78e5d61f43c2c40e760
4cc8d74f0fe3d655e16c337612d02a68208620169c6d7ca587a25fddce61c004
2f5d7b59ccb55b21d4a5b7d7e03c9966df5c1377f447098ab398e9b789a6a188
f1f2ba5d1a6e898688658006c8c42c0f6cb8a6e214f08d016cfc1a2a72af1e8e
02ded9879607050fc2531d8fb9265faadcc4c0a6ffced7a82eb919a0354f9c9c
4923c95ed744e8eee5d4f215b16c2a1141ec49bca7f616651f124865e06e4e1b