removed legacy files
This commit is contained in:
@ -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"]);
|
||||
}
|
||||
@ -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);
|
||||
@ -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"]);
|
||||
152
api/export.php
152
api/export.php
@ -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);
|
||||
@ -48,7 +48,6 @@ $routes = [
|
||||
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||
'download' => __DIR__ . '/../handlers/download.php',
|
||||
];
|
||||
|
||||
try {
|
||||
|
||||
@ -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]);
|
||||
@ -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"]);
|
||||
}
|
||||
@ -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"]);
|
||||
}
|
||||
143
api/results.php
143
api/results.php
@ -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
|
||||
]);
|
||||
@ -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"]);
|
||||
}
|
||||
274
api/users.php
274
api/users.php
@ -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"]);
|
||||
@ -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"]);
|
||||
42
backup.php
42
backup.php
@ -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",
|
||||
]);
|
||||
@ -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"]);
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
@ -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;
|
||||
78
db_view.php
78
db_view.php
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
@ -315,7 +315,7 @@ Android should apply this map while a questionnaire is open (overlay on top of a
|
||||
4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
|
||||
6. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
|
||||
7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
|
||||
8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` (plaintext JSON over HTTPS; server stores answers in the database — not the app's local ciphertext).
|
||||
8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` using an **encrypted** JSON envelope (see above).
|
||||
|
||||
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available.
|
||||
|
||||
@ -327,7 +327,31 @@ The Android app encrypts sensitive data **at rest on the device**:
|
||||
- **Assigned client list** cache and **session token** use the same encryption / EncryptedSharedPreferences respectively.
|
||||
- **Questionnaire definitions** and UI translations cached from this API are not encrypted (no user answers).
|
||||
|
||||
After a successful `POST /api/app_questionnaires`, answer data is readable on the **server** (normal database storage, RBAC-protected). The upload body is **decrypted plaintext** (AES-GCM applies only to the on-device Room database). The app keeps encrypted local copies so coaches can review, edit, and re-upload work offline.
|
||||
Sensitive mobile traffic uses **two layers**:
|
||||
|
||||
1. **HTTPS** (TLS) for transport.
|
||||
2. **Application payload encryption** for patient-related fields: AES-256-CBC with a per-session key derived from the Bearer token via HKDF-SHA256 (`info` = `qdb-aes`), matching the legacy QDB mobile crypto helpers.
|
||||
|
||||
Encrypted wire format (request or response `data`):
|
||||
|
||||
```json
|
||||
{
|
||||
"encrypted": true,
|
||||
"payload": "<base64( IV16 || ciphertext )>"
|
||||
}
|
||||
```
|
||||
|
||||
Endpoints using encrypted payloads:
|
||||
|
||||
| Endpoint | Direction |
|
||||
|----------|-----------|
|
||||
| `POST /api/auth/login` | Response: `clientsPayload` (when coach has clients) |
|
||||
| `GET /api/app_questionnaires?clients=1` | Response: entire `data` object encrypted |
|
||||
| `POST /api/app_questionnaires` | Request body must be encrypted |
|
||||
|
||||
Plain JSON (no app-layer encryption): `GET ?translations=1`, questionnaire list, questionnaire detail.
|
||||
|
||||
The server decrypts uploads before writing to the database. The web dashboard reads **server-side** plaintext (RBAC). The Android app also keeps **AES-GCM at rest** on device via Android Keystore (separate from wire encryption).
|
||||
|
||||
Re-uploading the same `clientCode` + `questionnaireID` updates server rows in place (see upload behavior below). This matches upcoming editable flows on web and mobile.
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -7,7 +7,7 @@
|
||||
* GET ?clients=1 -> list of clients assigned to the authenticated coach
|
||||
* POST -> submit interview answers for a client (coach / supervisor / admin).
|
||||
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
|
||||
* Mobile clients encrypt answers at rest on device; POST body is plaintext JSON over HTTPS.
|
||||
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
|
||||
*/
|
||||
|
||||
// Public app UI catalog for the login screen (no token, no PII).
|
||||
@ -23,7 +23,7 @@ $tokenRec = require_valid_token();
|
||||
if ($method === 'POST') {
|
||||
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
|
||||
|
||||
$body = read_json_body();
|
||||
$body = read_encrypted_json_body($tokenRec['_token']);
|
||||
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
|
||||
|
||||
$qnID = trim($body['questionnaireID']);
|
||||
@ -259,7 +259,7 @@ if ($fetchClients) {
|
||||
}, $clientCodes);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['coachID' => $coachID, 'clients' => $clients]);
|
||||
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
|
||||
}
|
||||
|
||||
if ($qnID) {
|
||||
|
||||
@ -67,12 +67,18 @@ case 'auth/login':
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
]);
|
||||
json_success([
|
||||
'token' => $token,
|
||||
'user' => $username,
|
||||
'role' => $user['role'],
|
||||
'clients' => $assignedClients,
|
||||
]);
|
||||
$loginData = [
|
||||
'token' => $token,
|
||||
'user' => $username,
|
||||
'role' => $user['role'],
|
||||
];
|
||||
if ($assignedClients !== []) {
|
||||
$loginData['clientsPayload'] = qdb_sensitive_envelope(
|
||||
json_encode(['clients' => $assignedClients], JSON_UNESCAPED_UNICODE),
|
||||
$token
|
||||
);
|
||||
}
|
||||
json_success($loginData);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
|
||||
@ -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;
|
||||
@ -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"
|
||||
]
|
||||
@ -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"]);
|
||||
}
|
||||
975
index.html
975
index.html
@ -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
25
lib/encrypted_payload.php
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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() ?: '';
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/encrypted_payload.php';
|
||||
|
||||
function json_success(mixed $data, int $status = 200): never {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
@ -22,3 +24,26 @@ function read_json_body(): array {
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function read_encrypted_json_body(string $tokenHex): array {
|
||||
$outer = read_json_body();
|
||||
if (empty($outer['encrypted'])) {
|
||||
json_error('ENCRYPTION_REQUIRED', 'Sensitive requests must send an encrypted payload', 400);
|
||||
}
|
||||
try {
|
||||
$plain = qdb_decrypt_sensitive_envelope($outer, $tokenHex);
|
||||
} catch (Throwable $e) {
|
||||
error_log('decrypt body failed: ' . $e->getMessage());
|
||||
json_error('DECRYPT_FAILED', 'Could not decrypt request payload', 400);
|
||||
}
|
||||
$data = json_decode($plain, true);
|
||||
if (!is_array($data)) {
|
||||
json_error('INVALID_BODY', 'Decrypted body must be valid JSON object', 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function json_success_sensitive(mixed $data, string $tokenHex, int $status = 200): never {
|
||||
$plain = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
json_success(qdb_sensitive_envelope($plain, $tokenHex), $status);
|
||||
}
|
||||
|
||||
85
login.php
85
login.php
@ -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"]);
|
||||
}
|
||||
236
manage_users.php
236
manage_users.php
@ -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"]);
|
||||
@ -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 (0–10) 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 (0–10) 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)?"
|
||||
}
|
||||
176
seed_user.php
176
seed_user.php
@ -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);
|
||||
}
|
||||
24
tokens.jsonl
24
tokens.jsonl
@ -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"}
|
||||
@ -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 (0–10) 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"
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
50
users.json
50
users.json
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
Reference in New Issue
Block a user