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', 'backup' => __DIR__ . '/../handlers/backup.php',
'dev' => __DIR__ . '/../handlers/dev.php', 'dev' => __DIR__ . '/../handlers/dev.php',
'dev/import' => __DIR__ . '/../handlers/dev.php', 'dev/import' => __DIR__ . '/../handlers/dev.php',
'download' => __DIR__ . '/../handlers/download.php',
]; ];
try { 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). 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`). 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. 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. 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. - **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). - **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. 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 * GET ?clients=1 -> list of clients assigned to the authenticated coach
* POST -> submit interview answers for a client (coach / supervisor / admin). * POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * 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). // Public app UI catalog for the login screen (no token, no PII).
@ -23,7 +23,7 @@ $tokenRec = require_valid_token();
if ($method === 'POST') { if ($method === 'POST') {
require_role(['admin', 'supervisor', 'coach'], $tokenRec); require_role(['admin', 'supervisor', 'coach'], $tokenRec);
$body = read_json_body(); $body = read_encrypted_json_body($tokenRec['_token']);
require_fields($body, ['questionnaireID', 'clientCode', 'answers']); require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']); $qnID = trim($body['questionnaireID']);
@ -259,7 +259,7 @@ if ($fetchClients) {
}, $clientCodes); }, $clientCodes);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['coachID' => $coachID, 'clients' => $clients]); json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
} }
if ($qnID) { if ($qnID) {

View File

@ -67,12 +67,18 @@ case 'auth/login':
'entityID' => $user['entityID'], 'entityID' => $user['entityID'],
'userID' => $user['userID'], 'userID' => $user['userID'],
]); ]);
json_success([ $loginData = [
'token' => $token, 'token' => $token,
'user' => $username, 'user' => $username,
'role' => $user['role'], 'role' => $user['role'],
'clients' => $assignedClients, ];
]); if ($assignedClients !== []) {
$loginData['clientsPayload'] = qdb_sensitive_envelope(
json_encode(['clients' => $assignedClients], JSON_UNESCAPED_UNICODE),
$token
);
}
json_success($loginData);
} catch (Throwable $e) { } catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage()); 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 <?php
require_once __DIR__ . '/encrypted_payload.php';
function json_success(mixed $data, int $status = 200): never { function json_success(mixed $data, int $status = 200): never {
http_response_code($status); http_response_code($status);
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
@ -22,3 +24,26 @@ function read_json_body(): array {
} }
return $data; 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