Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90bfd0bbd3 | |||
| 764b4c5545 | |||
| 9265181154 | |||
| 497246cd57 | |||
| 4d7be68080 | |||
| fc23f7a619 | |||
| f90d5db40a | |||
| 6b3cd0da17 | |||
| feae470fba | |||
| 810ed24792 | |||
| 351af170a0 | |||
| f04388e0ec | |||
| 4afab336ee | |||
| 181ca2b7d4 | |||
| 339091e13f | |||
| 9bd9d9653c | |||
| cecaf26c85 | |||
| 547ed8454e | |||
| 7232fc5a17 | |||
| 67858d34d9 | |||
| b02f264b23 | |||
| 623fe0f464 | |||
| 19f9faeb2d | |||
| 53a1dfac2a | |||
| 0add7c62e9 | |||
| 3d738bb14e | |||
| 5dd0ee28c0 | |||
| b06c4bf3ee | |||
| 6ae29f1648 | |||
| 71956c0927 | |||
| ebf27811db | |||
| 560ca69ae8 | |||
| b4d4de72f4 | |||
| 5dff24a232 | |||
| 59ea2cd667 | |||
| cceec525c1 | |||
| d807f753b1 | |||
| 5b5cc52133 | |||
| 63a560e803 | |||
| fadf60b2f7 | |||
| b54a33c78d | |||
| d16bd1b3c4 | |||
| 3211d35b43 | |||
| 11bae269e9 | |||
| 63adef79df | |||
| 4589be119c | |||
| 8fdaefe10b | |||
| e5ae48c02e | |||
| d68b2b267d | |||
| 91834d0b16 | |||
| c37940c9a9 | |||
| 6883654d7c | |||
| 48a619ee4b | |||
| d80a8de559 | |||
| ffe6d04d75 | |||
| fc84d55bd6 | |||
| d0daa7e937 | |||
| 30d3ab4a87 | |||
| 43c47bca6d | |||
| e4d55f4c90 | |||
| e5ac069dc4 | |||
| da394c5525 | |||
| 890232f58f | |||
| 82a1614a58 | |||
| 6895aa5674 | |||
| a5e58e88c1 | |||
| 6174d3c820 | |||
| 62c70581d9 | |||
| 3d449a5672 | |||
| d6cbe5fbe5 | |||
| 7ff2347b74 | |||
| 6c17166558 | |||
| 9ed66ca327 | |||
| 3d56abd124 | |||
| bee7b74e53 | |||
| 9a6fa22d84 | |||
| 9cf67e765f | |||
| b049a9ab9f | |||
| 8915c8d182 | |||
| 5d539c130f | |||
| 24080bb8b9 | |||
| 36b6d63266 | |||
| 9c887537e8 | |||
| 0f98393d1d | |||
| 5a2faa3ecc | |||
| e8f92cc6ca | |||
| d96d935614 | |||
| 0f89ed6d6f | |||
| 9a75577f14 | |||
| 5da345246b | |||
| 7f8cc4a632 | |||
| 1d9154e664 | |||
| ff370d0d39 | |||
| 25851c4b86 | |||
| ea3a423186 | |||
| 9d783b60a9 | |||
| 2181d1731e | |||
| 7671c9f329 | |||
| 1b3d74d822 | |||
| 8e56a42f7e | |||
| aefb1b6440 | |||
| 244fd16140 | |||
| be01b85569 | |||
| 472e45d730 | |||
| 2ded5b6aaf | |||
| 0291b8be7a | |||
| c86768e391 | |||
| e0be2c501f | |||
| 2616238dc8 | |||
| 52d4ef42dc | |||
| c86372af2e | |||
| 03291862f7 |
12
.env.example
Normal file
12
.env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# Database driver: mysql or sqlite (tests use sqlite)
|
||||
QDB_DRIVER=mysql
|
||||
|
||||
# MySQL connection (required when QDB_DRIVER=mysql)
|
||||
QDB_MYSQL_HOST=127.0.0.1
|
||||
QDB_MYSQL_PORT=3306
|
||||
QDB_MYSQL_DATABASE=nat-as-db
|
||||
QDB_MYSQL_USER=nat_as_db
|
||||
QDB_MYSQL_PASSWORD=change-me
|
||||
|
||||
# AES key for legacy SQLite file encryption and app payload crypto (32 bytes, base64)
|
||||
QDB_MASTER_KEY=
|
||||
52
.github/workflows/phpunit.yml
vendored
Normal file
52
.github/workflows/phpunit.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
name: PHPUnit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_DATABASE: nat_as_test
|
||||
MYSQL_USER: nat_as_test
|
||||
MYSQL_PASSWORD: test-password
|
||||
MYSQL_ROOT_PASSWORD: root-password
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot-password"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: json, openssl, pdo, pdo_mysql, pdo_sqlite, zip
|
||||
coverage: pcov
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Run tests with coverage floor
|
||||
run: php tests/check-coverage.php 40
|
||||
|
||||
- name: Run MySQL schema smoke test
|
||||
env:
|
||||
QDB_MYSQL_HOST: 127.0.0.1
|
||||
QDB_MYSQL_PORT: 3306
|
||||
QDB_MYSQL_DATABASE: nat_as_test
|
||||
QDB_MYSQL_USER: nat_as_test
|
||||
QDB_MYSQL_PASSWORD: test-password
|
||||
run: php tests/mysql_smoke.php
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -7,6 +7,8 @@ valid_tokens.txt
|
||||
# Encrypted database, temp files, backups
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
!uploads/logs/
|
||||
!uploads/logs/**
|
||||
|
||||
# Database lock files (just in case)
|
||||
uploads/.qdb_lock
|
||||
@ -14,6 +16,8 @@ uploads/*.lock
|
||||
|
||||
# Logs / dumps / exports
|
||||
*.log
|
||||
logs/api/*
|
||||
!logs/api/.gitkeep
|
||||
*.sqlite
|
||||
*.db
|
||||
*.dump
|
||||
@ -29,6 +33,8 @@ yarn.lock
|
||||
package-lock.json
|
||||
|
||||
# PHP composer artifacts (if using composer in future)
|
||||
tests/runtime/
|
||||
.phpunit.cache/
|
||||
vendor/
|
||||
composer.lock
|
||||
|
||||
@ -45,9 +51,7 @@ Thumbs.db
|
||||
# Misc
|
||||
*.orig
|
||||
*.rej
|
||||
|
||||
# Ignore schema if generated
|
||||
schema.sql
|
||||
*.md
|
||||
|
||||
# Ignore browser build artifacts (if any)
|
||||
website/js/dist/
|
||||
|
||||
@ -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();
|
||||
$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,176 +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 -> all translations merged across all tables
|
||||
*
|
||||
* 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';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$qnID = $_GET['id'] ?? '';
|
||||
$translations = $_GET['translations'] ?? '';
|
||||
|
||||
if ($translations) {
|
||||
// Return all translations merged from all three tables, keyed by language
|
||||
$result = [];
|
||||
|
||||
// question translations: map questionID -> defaultText (the key), then lang -> text
|
||||
$rows = $pdo->query("
|
||||
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
|
||||
FROM question_translation qt
|
||||
JOIN question q ON q.questionID = qt.questionID
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
// answer option translations: map answerOptionID -> defaultText (the key)
|
||||
$rows = $pdo->query("
|
||||
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
|
||||
FROM answer_option_translation aot
|
||||
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
// string translations (symptoms, hints, textKeys, etc.)
|
||||
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "translations" => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($qnID) {
|
||||
// Single questionnaire in app JSON format
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$qnRow) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, configJson
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$stmt->execute([':id' => $qnID]);
|
||||
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$questions = [];
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$localId = $dbQ['questionID'];
|
||||
$parts = explode('__', $localId);
|
||||
$shortId = end($parts);
|
||||
|
||||
$layout = $dbQ['type'];
|
||||
$config = json_decode($dbQ['configJson'], true) ?: [];
|
||||
|
||||
$q = [
|
||||
'id' => $shortId,
|
||||
'layout' => $layout,
|
||||
'question' => $dbQ['defaultText'],
|
||||
];
|
||||
|
||||
// Add type-specific config fields back to the top level
|
||||
if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
|
||||
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
|
||||
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
|
||||
if (isset($config['hint'])) $q['hint'] = $config['hint'];
|
||||
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
|
||||
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
|
||||
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
|
||||
if (isset($config['range'])) $q['range'] = $config['range'];
|
||||
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
|
||||
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
|
||||
|
||||
// String spinner static options
|
||||
if ($layout === 'string_spinner' && isset($config['options'])) {
|
||||
$q['options'] = $config['options'];
|
||||
}
|
||||
|
||||
// Value spinner conditional navigation options
|
||||
if ($layout === 'value_spinner' && isset($config['valueOptions'])) {
|
||||
$q['options'] = $config['valueOptions'];
|
||||
}
|
||||
|
||||
// Answer options (for radio_question, multi_check_box_question, etc.)
|
||||
$ao = $pdo->prepare("
|
||||
SELECT defaultText, points, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$ao->execute([':qid' => $dbQ['questionID']]);
|
||||
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($opts) {
|
||||
$optionsArr = [];
|
||||
$pointsMap = [];
|
||||
foreach ($opts as $opt) {
|
||||
$o = ['key' => $opt['defaultText']];
|
||||
if ($opt['nextQuestionId'] !== '') {
|
||||
$o['nextQuestionId'] = $opt['nextQuestionId'];
|
||||
}
|
||||
$optionsArr[] = $o;
|
||||
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
|
||||
}
|
||||
$q['options'] = $optionsArr;
|
||||
|
||||
$hasNonZero = false;
|
||||
foreach ($pointsMap as $v) { if ($v !== 0) { $hasNonZero = true; break; } }
|
||||
$q['pointsMap'] = $pointsMap;
|
||||
}
|
||||
|
||||
$questions[] = $q;
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode([
|
||||
"meta" => ["id" => $qnID],
|
||||
"questions" => $questions,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Default: ordered questionnaire list
|
||||
$rows = $pdo->query("
|
||||
SELECT questionnaireID, name, showPoints, conditionJson
|
||||
FROM questionnaire
|
||||
WHERE state = 'active'
|
||||
ORDER BY orderIndex
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$list[] = [
|
||||
'id' => $r['questionnaireID'],
|
||||
'name' => $r['name'],
|
||||
'showPoints' => (bool)(int)$r['showPoints'],
|
||||
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
|
||||
];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode($list);
|
||||
@ -1,117 +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();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ── GET: coaches + clients scoped by RBAC ────────────────────────────────
|
||||
if ($method === 'GET') {
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
if ($callerRole === 'admin') {
|
||||
$coaches = $pdo->query(
|
||||
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
|
||||
FROM coach c
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
|
||||
ORDER BY sv.username, c.username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
|
||||
FROM coach c
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
|
||||
WHERE c.supervisorID = :sid ORDER BY c.username"
|
||||
);
|
||||
$stmt->execute([':sid' => $callerEntityID]);
|
||||
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec);
|
||||
$clientStmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
||||
FROM client cl
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
WHERE $clause
|
||||
ORDER BY cl.coachID, cl.clientCode"
|
||||
);
|
||||
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
|
||||
$clientStmt->execute();
|
||||
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "coaches" => $coaches, "clients" => $clients]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: assign clients to a coach ──────────────────────────────────────
|
||||
if ($method === 'POST') {
|
||||
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$clientCodes = $in['clientCodes'] ?? [];
|
||||
$coachID = trim($in['coachID'] ?? '');
|
||||
|
||||
if (empty($clientCodes) || $coachID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "clientCodes and coachID required"]);
|
||||
exit;
|
||||
}
|
||||
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
if ($callerRole === 'supervisor') {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
|
||||
$chk->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
|
||||
} else {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
|
||||
$chk->execute([':cid' => $coachID]);
|
||||
}
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Coach not found or not authorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only allow reassignment of clients the caller can already see
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
|
||||
$pdo->beginTransaction();
|
||||
$updStmt = $pdo->prepare(
|
||||
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$count = 0;
|
||||
foreach ($clientCodes as $cc) {
|
||||
$cc = trim($cc);
|
||||
if ($cc === '') continue;
|
||||
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
|
||||
$count += $updStmt->rowCount();
|
||||
}
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode(["success" => true, "assigned" => $count]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
138
api/export.php
138
api/export.php
@ -1,138 +0,0 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
$format = strtolower($_GET['format'] ?? 'csv');
|
||||
|
||||
if (!$qnID) {
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID query param required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
// Questionnaire metadata
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$questionnaire) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Questions ordered
|
||||
$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
|
||||
// Build answer-option lookup for resolving display text
|
||||
$optionTextMap = [];
|
||||
foreach ($questionIDs as $qid) {
|
||||
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid");
|
||||
$aoStmt->execute([':qid' => $qid]);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC filter
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
WHERE $rbacClause
|
||||
ORDER BY cl.clientCode
|
||||
";
|
||||
$params = array_merge([':qnid' => $qnID], $rbacParams);
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Fetch answers for each client
|
||||
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
$rows = [];
|
||||
foreach ($clients as $c) {
|
||||
$row = [
|
||||
'clientCode' => $c['clientCode'],
|
||||
'coachID' => $c['coachID'],
|
||||
'status' => $c['status'],
|
||||
'sumPoints' => $c['sumPoints'],
|
||||
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
|
||||
'completedAt'=> $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
||||
];
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$qid = $q['questionID'];
|
||||
if (isset($answerMap[$qid])) {
|
||||
$a = $answerMap[$qid];
|
||||
if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) {
|
||||
$row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']];
|
||||
} elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') {
|
||||
$row[$q['defaultText']] = $a['freeTextValue'];
|
||||
} elseif ($a['numericValue'] !== null) {
|
||||
$row[$q['defaultText']] = $a['numericValue'];
|
||||
} else {
|
||||
$row[$q['defaultText']] = '';
|
||||
}
|
||||
} else {
|
||||
$row[$q['defaultText']] = '';
|
||||
}
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
// Output CSV
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
|
||||
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
|
||||
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// BOM for Excel UTF-8 recognition
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
if (!empty($rows)) {
|
||||
fputcsv($out, array_keys($rows[0]));
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, array_values($row));
|
||||
}
|
||||
} else {
|
||||
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
foreach ($questions as $q) $header[] = $q['defaultText'];
|
||||
fputcsv($out, $header);
|
||||
}
|
||||
fclose($out);
|
||||
@ -6,21 +6,11 @@
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
require_once __DIR__ . '/../lib/response.php';
|
||||
require_once __DIR__ . '/../lib/errors.php';
|
||||
require_once __DIR__ . '/../lib/validate.php';
|
||||
require_once __DIR__ . '/../lib/api_log.php';
|
||||
|
||||
// CORS
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
// Parse route: strip /api/ prefix and .php suffix, normalize
|
||||
// Parse route early (for access logs)
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||
$base = dirname($_SERVER['SCRIPT_NAME']);
|
||||
@ -30,6 +20,23 @@ $route = strtolower($route);
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if (!defined('QDB_TESTING')) {
|
||||
qdb_api_log_begin($method, $route !== '' ? $route : '(root)');
|
||||
register_shutdown_function('qdb_api_log_finish');
|
||||
}
|
||||
|
||||
// CORS
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-QDB-Client');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
// Dispatch table: route -> handler file
|
||||
$routes = [
|
||||
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
|
||||
@ -41,12 +48,22 @@ $routes = [
|
||||
'clients' => __DIR__ . '/../handlers/clients.php',
|
||||
'results' => __DIR__ . '/../handlers/results.php',
|
||||
'export' => __DIR__ . '/../handlers/export.php',
|
||||
'analytics' => __DIR__ . '/../handlers/analytics.php',
|
||||
'scoring_profiles' => __DIR__ . '/../handlers/scoring_profiles.php',
|
||||
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||
'session' => __DIR__ . '/../handlers/session.php',
|
||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
||||
'auth/keycloak-config' => __DIR__ . '/../handlers/auth.php',
|
||||
'auth/keycloak-login' => __DIR__ . '/../handlers/auth.php',
|
||||
'auth/keycloak-callback' => __DIR__ . '/../handlers/auth.php',
|
||||
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||
'download' => __DIR__ . '/../handlers/download.php',
|
||||
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
|
||||
'settings' => __DIR__ . '/../handlers/settings.php',
|
||||
];
|
||||
|
||||
try {
|
||||
@ -62,6 +79,13 @@ try {
|
||||
require $handlerFile;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("Unhandled error on $method $route: " . $e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Internal server error', 500);
|
||||
if (defined('QDB_TESTING') && (
|
||||
$e instanceof \Tests\Support\JsonSuccessResponse
|
||||
|| $e instanceof \Tests\Support\JsonErrorResponse
|
||||
|| $e instanceof \Tests\Support\RawHttpResponse
|
||||
)) {
|
||||
throw $e;
|
||||
}
|
||||
require_once __DIR__ . '/../lib/errors.php';
|
||||
qdb_emit_api_error($e, "API $method $route");
|
||||
}
|
||||
|
||||
@ -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();
|
||||
$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();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
if (!$qnID) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID query param required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
|
||||
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$stmt->execute([':qid' => $qnID]);
|
||||
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($questions as &$q) {
|
||||
$q['isRequired'] = (int)$q['isRequired'];
|
||||
$q['orderIndex'] = (int)$q['orderIndex'];
|
||||
$q['configJson'] = $q['configJson'] ?: '{}';
|
||||
$ao = $pdo->prepare("
|
||||
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$ao->execute([':qid' => $q['questionID']]);
|
||||
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($q['answerOptions'] as &$opt) {
|
||||
$opt['points'] = (int)$opt['points'];
|
||||
$opt['orderIndex'] = (int)$opt['orderIndex'];
|
||||
}
|
||||
|
||||
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
|
||||
$tr->execute([':qid' => $q['questionID']]);
|
||||
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
unset($q);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "questions" => $questions]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionnaireID']) || !isset($body['defaultText'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID and defaultText required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$qnID = $body['questionnaireID'];
|
||||
$text = trim($body['defaultText']);
|
||||
$type = trim($body['type'] ?? '');
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$req = (int)($body['isRequired'] ?? 0);
|
||||
$config = $body['configJson'] ?? '{}';
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
|
||||
$chk->execute([':id' => $qnID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($order === 0) {
|
||||
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM question WHERE questionnaireID = :id");
|
||||
$max->execute([':id' => $qnID]);
|
||||
$order = (int)$max->fetchColumn();
|
||||
}
|
||||
|
||||
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
|
||||
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
|
||||
':o' => $order, ':r' => $req, ':cj' => $config]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "question" => [
|
||||
"questionID" => $id, "questionnaireID" => $qnID, "defaultText" => $text,
|
||||
"type" => $type, "orderIndex" => $order, "isRequired" => $req,
|
||||
"configJson" => $config, "answerOptions" => [], "translations" => []
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['questionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
|
||||
$existing->execute([':id' => $id]);
|
||||
$row = $existing->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Question not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||
$type = trim($body['type'] ?? $row['type']);
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$req = (int)($body['isRequired'] ?? $row['isRequired']);
|
||||
$config = $body['configJson'] ?? $row['configJson'];
|
||||
|
||||
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
|
||||
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "question" => [
|
||||
"questionID" => $id, "questionnaireID" => $row['questionnaireID'],
|
||||
"defaultText" => $text, "type" => $type, "orderIndex" => $order, "isRequired" => $req,
|
||||
"configJson" => $config
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['questionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id");
|
||||
$aoIds->execute([':id' => $id]);
|
||||
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
|
||||
}
|
||||
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PATCH':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID and order[] required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$stmt = $pdo->prepare("UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid");
|
||||
foreach ($body['order'] as $idx => $qid) {
|
||||
$stmt->execute([':o' => $idx, ':id' => $qid, ':qid' => $body['questionnaireID']]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
}
|
||||
139
api/results.php
139
api/results.php
@ -1,139 +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();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
$clientCode = $_GET['clientCode'] ?? '';
|
||||
|
||||
if (!$qnID) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID query param required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
// Questionnaire metadata
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$questionnaire) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Questions with answer options
|
||||
$qStmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, isRequired
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
|
||||
foreach ($questions as &$q) {
|
||||
$q['isRequired'] = (int)$q['isRequired'];
|
||||
$q['orderIndex'] = (int)$q['orderIndex'];
|
||||
$ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
|
||||
$ao->execute([':qid' => $q['questionID']]);
|
||||
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($q['answerOptions'] as &$opt) {
|
||||
$opt['points'] = (int)$opt['points'];
|
||||
$opt['orderIndex'] = (int)$opt['orderIndex'];
|
||||
}
|
||||
}
|
||||
unset($q);
|
||||
|
||||
// RBAC filter
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
// Build client list
|
||||
if ($clientCode) {
|
||||
$clientSQL = "SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND $rbacClause";
|
||||
$clientParams = array_merge([':cc' => $clientCode], $rbacParams);
|
||||
} else {
|
||||
$clientSQL = "SELECT cl.clientCode, cl.coachID FROM client cl WHERE $rbacClause";
|
||||
$clientParams = $rbacParams;
|
||||
}
|
||||
|
||||
// Only clients that have a completed_questionnaire entry for this questionnaire
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
WHERE $rbacClause
|
||||
";
|
||||
$params = array_merge([':qnid' => $qnID], $rbacParams);
|
||||
|
||||
if ($clientCode) {
|
||||
$sql .= " AND cl.clientCode = :cc";
|
||||
$params[':cc'] = $clientCode;
|
||||
}
|
||||
$sql .= " ORDER BY cl.clientCode";
|
||||
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Build question ID placeholders for answer fetch
|
||||
if (!empty($questionIDs) && !empty($clients)) {
|
||||
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
foreach ($clients as &$c) {
|
||||
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
|
||||
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
|
||||
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) {
|
||||
$answerMap[$a['questionID']] = [
|
||||
'answerOptionID' => $a['answerOptionID'],
|
||||
'freeTextValue' => $a['freeTextValue'],
|
||||
'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null,
|
||||
'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null,
|
||||
];
|
||||
}
|
||||
$c['answers'] = $answerMap;
|
||||
}
|
||||
unset($c);
|
||||
} else {
|
||||
foreach ($clients as &$c) {
|
||||
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
|
||||
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
|
||||
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
|
||||
$c['answers'] = (object)[];
|
||||
}
|
||||
unset($c);
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"questionnaire" => $questionnaire,
|
||||
"questions" => $questions,
|
||||
"clients" => $clients
|
||||
]);
|
||||
@ -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();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
$validTypes = ['question', 'answer_option', 'string', 'language'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
// GET ?languages=1 -> list all languages
|
||||
if (isset($_GET['languages'])) {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "languages" => $rows]);
|
||||
break;
|
||||
}
|
||||
|
||||
// GET ?questionnaireID=X -> batch: all keys + all translations for a questionnaire
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
if ($qnID) {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Question keys + translations
|
||||
$qStmt = $pdo->prepare("
|
||||
SELECT q.questionID, q.defaultText, q.configJson
|
||||
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
|
||||
");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$entries = [];
|
||||
$stringKeys = [];
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$entries[] = [
|
||||
'key' => $dbQ['defaultText'],
|
||||
'type' => 'question',
|
||||
'entityId' => $dbQ['questionID'],
|
||||
];
|
||||
|
||||
// Answer option keys
|
||||
$aoStmt = $pdo->prepare("
|
||||
SELECT answerOptionID, defaultText FROM answer_option
|
||||
WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$entries[] = [
|
||||
'key' => $ao['defaultText'],
|
||||
'type' => 'answer_option',
|
||||
'entityId' => $ao['answerOptionID'],
|
||||
];
|
||||
}
|
||||
|
||||
// String keys from configJson
|
||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||
foreach (['textKey','textKey1','textKey2','hint','hint1','hint2'] as $field) {
|
||||
if (!empty($config[$field])) $stringKeys[$config[$field]] = true;
|
||||
}
|
||||
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
||||
foreach ($config['symptoms'] as $s) $stringKeys[$s] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($stringKeys) as $sk) {
|
||||
$entries[] = [
|
||||
'key' => $sk,
|
||||
'type' => 'string',
|
||||
'entityId' => $sk,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch all existing translations for these entities
|
||||
$translations = [];
|
||||
|
||||
// question translations
|
||||
$qIds = array_filter(array_map(fn($e) => $e['type'] === 'question' ? $e['entityId'] : null, $entries));
|
||||
if ($qIds) {
|
||||
$ph = implode(',', array_fill(0, count($qIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
|
||||
$stmt->execute(array_values($qIds));
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||
}
|
||||
}
|
||||
|
||||
// answer_option translations
|
||||
$aoIds = array_filter(array_map(fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null, $entries));
|
||||
if ($aoIds) {
|
||||
$ph = implode(',', array_fill(0, count($aoIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
|
||||
$stmt->execute(array_values($aoIds));
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||
}
|
||||
}
|
||||
|
||||
// string translations
|
||||
$sKeys = array_filter(array_map(fn($e) => $e['type'] === 'string' ? $e['entityId'] : null, $entries));
|
||||
if ($sKeys) {
|
||||
$ph = implode(',', array_fill(0, count($sKeys), '?'));
|
||||
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
|
||||
$stmt->execute(array_values($sKeys));
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||
}
|
||||
}
|
||||
|
||||
// Attach translations to entries
|
||||
foreach ($entries as &$e) {
|
||||
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
|
||||
}
|
||||
unset($e);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"languages" => $languages,
|
||||
"entries" => $entries,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Original single-entity fetch
|
||||
$type = $_GET['type'] ?? '';
|
||||
$id = $_GET['id'] ?? '';
|
||||
if (!in_array($type, $validTypes, true) || (!$id && $type !== 'string')) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "type (question|answer_option|string) and id required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
if ($type === 'question') {
|
||||
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
$stmt = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} else {
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("SELECT stringKey, languageCode, text FROM string_translation WHERE stringKey = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} else {
|
||||
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
|
||||
}
|
||||
}
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "translations" => $rows]);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$type = $body['type'] ?? '';
|
||||
|
||||
// Language management
|
||||
if ($type === 'language') {
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
$name = trim($body['name'] ?? '');
|
||||
if (!$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
|
||||
->execute([':lc' => $lang, ':n' => $name]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$id = $body['id'] ?? '';
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
$text = $body['text'] ?? '';
|
||||
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "type, id, and languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
if ($type === 'question') {
|
||||
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
} else {
|
||||
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$type = $body['type'] ?? '';
|
||||
|
||||
// Language deletion
|
||||
if ($type === 'language') {
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$id = $body['id'] ?? '';
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "type, id, and languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
if ($type === 'question') {
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang")
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang")
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
} else {
|
||||
$pdo->prepare("DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang")
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
}
|
||||
275
api/users.php
275
api/users.php
@ -1,275 +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();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ── GET: list users (+ supervisors for admin) ─────────────────────────────
|
||||
if ($method === 'GET') {
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
if ($callerRole === 'admin') {
|
||||
$users = $pdo->query(
|
||||
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
|
||||
COALESCE(a.location, s.location, '') AS location,
|
||||
c.supervisorID,
|
||||
sv.username AS supervisorUsername
|
||||
FROM users u
|
||||
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
|
||||
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
|
||||
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
|
||||
ORDER BY u.role, u.username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$supervisors = $pdo->query(
|
||||
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
// Supervisor: only their coaches
|
||||
$svNameStmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
|
||||
$svNameStmt->execute([':svid' => $callerEntityID]);
|
||||
$svName = $svNameStmt->fetchColumn() ?: '';
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
|
||||
'' AS location, c.supervisorID, :svname AS supervisorUsername
|
||||
FROM users u
|
||||
JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
|
||||
WHERE c.supervisorID = :svid
|
||||
ORDER BY u.username"
|
||||
);
|
||||
$stmt->execute([':svid' => $callerEntityID, ':svname' => $svName]);
|
||||
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$supervisors = [];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"users" => $users,
|
||||
"supervisors" => $supervisors,
|
||||
"callerRole" => $callerRole,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: create a user ───────────────────────────────────────────────────
|
||||
if ($method === 'POST') {
|
||||
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$username = trim($in['username'] ?? '');
|
||||
$password = (string)($in['password'] ?? '');
|
||||
$role = strtolower(trim($in['role'] ?? ''));
|
||||
$location = trim($in['location'] ?? '');
|
||||
$supervisorID = trim($in['supervisorID'] ?? '');
|
||||
$mustChange = isset($in['mustChangePassword']) ? (int)(bool)$in['mustChangePassword'] : 1;
|
||||
|
||||
if ($username === '' || $password === '' || $role === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "username, password, and role are required"]);
|
||||
exit;
|
||||
}
|
||||
if ($callerRole === 'supervisor' && $role !== 'coach') {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Supervisors may only create coaches"]);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "role must be admin, supervisor, or coach"]);
|
||||
exit;
|
||||
}
|
||||
if (strlen($password) < 6) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Password must be at least 6 characters"]);
|
||||
exit;
|
||||
}
|
||||
// Supervisors always create coaches under themselves
|
||||
if ($callerRole === 'supervisor') {
|
||||
$supervisorID = $callerEntityID;
|
||||
}
|
||||
if ($role === 'coach' && $supervisorID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "supervisorID is required for coaches"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
|
||||
$chk->execute([':u' => $username]);
|
||||
if ($chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(409);
|
||||
echo json_encode(["error" => "Username already exists"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($role === 'coach') {
|
||||
$svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
|
||||
$svCheck->execute([':sid' => $supervisorID]);
|
||||
if (!$svCheck->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Supervisor not found"]);
|
||||
exit;
|
||||
}
|
||||
if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Cannot create coaches for another supervisor"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$entityID = bin2hex(random_bytes(16));
|
||||
$userID = bin2hex(random_bytes(16));
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$now = time();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
switch ($role) {
|
||||
case 'admin':
|
||||
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
break;
|
||||
case 'supervisor':
|
||||
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
break;
|
||||
case 'coach':
|
||||
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
|
||||
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
|
||||
break;
|
||||
}
|
||||
$pdo->prepare(
|
||||
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
|
||||
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)"
|
||||
)->execute([
|
||||
':uid' => $userID, ':u' => $username, ':hash' => $hash,
|
||||
':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now,
|
||||
]);
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
// Fetch supervisor name for response
|
||||
$svUsername = null;
|
||||
if ($role === 'coach') {
|
||||
[$pdo2, $tmp2, $lk2] = qdb_open(false);
|
||||
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
|
||||
$svr->execute([':sid' => $supervisorID]);
|
||||
$svUsername = $svr->fetchColumn() ?: null;
|
||||
qdb_discard($tmp2, $lk2);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"userID" => $userID,
|
||||
"entityID" => $entityID,
|
||||
"username" => $username,
|
||||
"role" => $role,
|
||||
"location" => $location,
|
||||
"supervisorID" => $role === 'coach' ? $supervisorID : null,
|
||||
"supervisorUsername" => $svUsername,
|
||||
"mustChangePassword" => $mustChange,
|
||||
"createdAt" => $now,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── DELETE: remove a user ─────────────────────────────────────────────────
|
||||
if ($method === 'DELETE') {
|
||||
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$targetUserID = trim($in['userID'] ?? '');
|
||||
|
||||
if ($targetUserID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "userID is required"]);
|
||||
exit;
|
||||
}
|
||||
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Cannot delete your own account"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
$target = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$target) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "User not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($callerRole === 'supervisor') {
|
||||
if ($target['role'] !== 'coach') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Supervisors can only delete coaches"]);
|
||||
exit;
|
||||
}
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid");
|
||||
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Coach is not under your supervision"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare("DELETE FROM users WHERE userID = :uid")->execute([':uid' => $targetUserID]);
|
||||
switch ($target['role']) {
|
||||
case 'admin':
|
||||
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")->execute([':id' => $target['entityID']]);
|
||||
break;
|
||||
case 'supervisor':
|
||||
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")->execute([':id' => $target['entityID']]);
|
||||
break;
|
||||
case 'coach':
|
||||
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")->execute([':id' => $target['entityID']]);
|
||||
break;
|
||||
}
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
@ -1,118 +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();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
$role = $tokenRec['role'];
|
||||
$entityID = $tokenRec['entityID'] ?? '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
// Coaches visible to this user
|
||||
if ($role === 'admin') {
|
||||
$coaches = $pdo->query(
|
||||
"SELECT c.coachID, c.username, c.supervisorID FROM coach c ORDER BY c.username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT c.coachID, c.username, c.supervisorID FROM coach c WHERE c.supervisorID = :sid ORDER BY c.username"
|
||||
);
|
||||
$stmt->execute([':sid' => $entityID]);
|
||||
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// Clients visible to this user
|
||||
[$clause, $params] = rbac_client_filter($tokenRec);
|
||||
$clientStmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
||||
FROM client cl
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
WHERE $clause
|
||||
ORDER BY cl.clientCode"
|
||||
);
|
||||
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
|
||||
$clientStmt->execute();
|
||||
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$pdo = null;
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode(["coaches" => $coaches, "clients" => $clients]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$raw = file_get_contents("php://input");
|
||||
$in = json_decode($raw, true) ?: [];
|
||||
|
||||
$clientCodes = $in['clientCodes'] ?? [];
|
||||
$coachID = trim($in['coachID'] ?? '');
|
||||
|
||||
if (empty($clientCodes) || $coachID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "clientCodes and coachID required"]);
|
||||
exit;
|
||||
}
|
||||
if (!is_array($clientCodes)) {
|
||||
$clientCodes = [$clientCodes];
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
// Verify coach exists and is visible
|
||||
if ($role === 'supervisor') {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
|
||||
$chk->execute([':cid' => $coachID, ':sid' => $entityID]);
|
||||
} else {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
|
||||
$chk->execute([':cid' => $coachID]);
|
||||
}
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Coach not found or not authorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only allow reassignment of clients the caller can already see
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
|
||||
$pdo->beginTransaction();
|
||||
$updStmt = $pdo->prepare(
|
||||
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$count = 0;
|
||||
foreach ($clientCodes as $cc) {
|
||||
$cc = trim($cc);
|
||||
if ($cc === '') continue;
|
||||
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
|
||||
$count += $updStmt->rowCount();
|
||||
}
|
||||
$pdo->commit();
|
||||
$pdo = null;
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "assigned" => $count]);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
42
backup.php
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();
|
||||
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,103 +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 (!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]);
|
||||
}
|
||||
182
cli/diagnose_db.php
Normal file
182
cli/diagnose_db.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* CLI: Print why the encrypted DB cannot be opened (deploy / env / permissions).
|
||||
* Usage: php cli/diagnose_db.php [path/to/questionnaire_database]
|
||||
*/
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
echo "CLI only\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
/** @return array{ok: bool, error?: string} */
|
||||
function qdb_probe_decrypt(string $dbPath, string $keyBytes): array {
|
||||
if (!is_file($dbPath)) {
|
||||
return ['ok' => false, 'error' => 'file missing'];
|
||||
}
|
||||
$storedEnc = @file_get_contents($dbPath);
|
||||
if ($storedEnc === false || strlen($storedEnc) < 17) {
|
||||
return ['ok' => false, 'error' => 'unreadable or too short'];
|
||||
}
|
||||
try {
|
||||
aes256_cbc_decrypt_bytes($storedEnc, $keyBytes);
|
||||
return ['ok' => true];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
$envPath = __DIR__ . '/../.env';
|
||||
echo "Project: " . realpath(__DIR__ . '/..') . "\n";
|
||||
echo ".env: " . ($envPath && is_readable($envPath) ? "readable" : "MISSING or not readable") . "\n";
|
||||
|
||||
$keyEnv = getenv('QDB_MASTER_KEY');
|
||||
if ($keyEnv === false || $keyEnv === '') {
|
||||
echo "QDB_MASTER_KEY: NOT SET (set in .env or Apache/PHP-FPM env)\n";
|
||||
if (!qdb_is_mysql()) {
|
||||
exit(1);
|
||||
}
|
||||
} else {
|
||||
echo "QDB_MASTER_KEY: set (" . strlen($keyEnv) . " chars)\n";
|
||||
$b64 = base64_decode($keyEnv, true);
|
||||
echo " parses as base64: " . ($b64 !== false && strlen($b64) > 0 ? 'yes (' . strlen($b64) . ' bytes)' : 'no (uses first 32 ASCII chars)') . "\n";
|
||||
}
|
||||
|
||||
if (qdb_is_mysql()) {
|
||||
echo "QDB_DRIVER: mysql\n";
|
||||
echo " host: " . (qdb_env_get('QDB_MYSQL_HOST') ?: '127.0.0.1') . "\n";
|
||||
echo " database: " . (qdb_env_get('QDB_MYSQL_DATABASE') ?: 'nat-as-db') . "\n";
|
||||
try {
|
||||
[$pdo] = qdb_mysql_open();
|
||||
qdb_mysql_ensure_ready($pdo);
|
||||
$version = qdb_schema_version($pdo);
|
||||
$users = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
$hasSession = qdb_table_exists($pdo, 'session');
|
||||
echo "\nOK: MySQL connection + migrations succeeded.\n";
|
||||
echo " schema_version: $version (expected " . QDB_VERSION . ")\n";
|
||||
echo " users: $users\n";
|
||||
echo " session table: " . ($hasSession ? 'yes' : 'MISSING') . "\n";
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
echo "\nMySQL open failed: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "QDB_DRIVER: sqlite\n";
|
||||
|
||||
$dbPath = $argv[1] ?? QDB_PATH;
|
||||
if ($dbPath !== QDB_PATH && !is_file($dbPath)) {
|
||||
echo "File not found: $dbPath\n";
|
||||
exit(1);
|
||||
}
|
||||
if ($dbPath !== QDB_PATH) {
|
||||
echo "Note: probing alternate file (app still uses " . QDB_PATH . " at runtime)\n";
|
||||
}
|
||||
$uploads = dirname($dbPath);
|
||||
echo "uploads dir: $uploads\n";
|
||||
echo " exists: " . (is_dir($uploads) ? 'yes' : 'no') . "\n";
|
||||
echo " writable: " . (is_writable($uploads) ? 'yes' : 'NO — migrations cannot save') . "\n";
|
||||
echo "DB file: $dbPath\n";
|
||||
if (is_file($dbPath)) {
|
||||
$mtime = filemtime($dbPath);
|
||||
echo " exists: yes (" . filesize($dbPath) . " bytes)\n";
|
||||
echo " modified: " . ($mtime ? date('Y-m-d H:i:s T', $mtime) : '?') . "\n";
|
||||
} else {
|
||||
echo " exists: no (will create on first write)\n";
|
||||
}
|
||||
|
||||
$backupDir = $uploads . '/backups';
|
||||
if (is_dir($backupDir)) {
|
||||
$backups = glob($backupDir . '/questionnaire_database.*') ?: [];
|
||||
rsort($backups);
|
||||
echo "Backups in uploads/backups: " . count($backups) . "\n";
|
||||
foreach (array_slice($backups, 0, 5) as $bp) {
|
||||
$mt = filemtime($bp);
|
||||
echo " - " . basename($bp) . ' (' . filesize($bp) . " bytes, " . ($mt ? date('Y-m-d H:i:s', $mt) : '?') . ")\n";
|
||||
}
|
||||
} else {
|
||||
echo "Backups: none (uploads/backups missing)\n";
|
||||
}
|
||||
|
||||
if (!is_file($dbPath)) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$candidates = [
|
||||
'current .env (get_master_key_bytes)' => get_master_key_bytes(),
|
||||
'legacy fallback (pre-April 2026 default)' => str_pad('12345678901234567890123456789012', 32, "\0"),
|
||||
'raw first 32 chars of QDB_MASTER_KEY' => str_pad(substr($keyEnv, 0, 32), 32, "\0"),
|
||||
];
|
||||
if ($b64 !== false && strlen($b64) > 0) {
|
||||
$candidates['base64-decoded .env only'] = str_pad(substr($b64, 0, 32), 32, "\0");
|
||||
}
|
||||
|
||||
echo "\nDecrypt probe (which key matches questionnaire_database?):\n";
|
||||
$matchedLabel = null;
|
||||
$matchedKey = null;
|
||||
$appKey = get_master_key_bytes();
|
||||
foreach ($candidates as $label => $keyBytes) {
|
||||
$probe = qdb_probe_decrypt($dbPath, $keyBytes);
|
||||
if ($probe['ok']) {
|
||||
echo " MATCH: $label\n";
|
||||
if ($matchedLabel === null) {
|
||||
$matchedLabel = $label;
|
||||
$matchedKey = $keyBytes;
|
||||
}
|
||||
} else {
|
||||
echo " no: $label\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedLabel === null) {
|
||||
echo "\nNo known key opened the file. Likely causes:\n";
|
||||
echo " - DB was encrypted with a different QDB_MASTER_KEY than in .env now\n";
|
||||
echo " - File was corrupted (e.g. interrupted save); try an older uploads/backups copy\n";
|
||||
echo " - Wrong DB path (another install under /var/www/...)\n";
|
||||
echo "\nProbe other copies, e.g.:\n";
|
||||
echo " php cli/diagnose_db.php /var/www/html/qdb/uploads/questionnaire_database\n";
|
||||
echo "\nWiping is NOT required for schema updates. Only wipe if you accept data loss\n";
|
||||
echo "and run: mv uploads/questionnaire_database uploads/questionnaire_database.bak\n";
|
||||
echo "then: php seed_admin.php <user> <pass>\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$dbMatchesAppKey = $matchedKey !== null && hash_equals($matchedKey, $appKey);
|
||||
|
||||
if ($dbPath !== QDB_PATH && $dbMatchesAppKey) {
|
||||
echo "\nThis file decrypts with your .env. Restore it into the app uploads dir:\n";
|
||||
echo " cp " . escapeshellarg($dbPath) . ' ' . escapeshellarg(QDB_PATH) . "\n";
|
||||
echo " chown www-data:www-data " . escapeshellarg(QDB_PATH) . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (!$dbMatchesAppKey) {
|
||||
echo "\nThe DB does NOT match get_master_key_bytes() but matches: $matchedLabel\n";
|
||||
echo "Fix: align QDB_MASTER_KEY in .env and php-fpm pool env, then re-run diagnose.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($dbPath !== QDB_PATH) {
|
||||
echo "\nDecrypt OK for alternate path. Run without arguments after copying into uploads/.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$version = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||
$users = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
$hasSession = qdb_table_exists($pdo, 'session');
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo "\nOK: full open + migrations succeeded.\n";
|
||||
echo " PRAGMA user_version: $version (expected " . QDB_VERSION . ")\n";
|
||||
echo " users: $users\n";
|
||||
echo " session table: " . ($hasSession ? 'yes' : 'MISSING') . "\n";
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
echo "\nDecrypt OK but qdb_open failed: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
77
cli/diagnose_login.php
Normal file
77
cli/diagnose_login.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* CLI: Reproduce login DB + token_add steps (same code path as auth/login).
|
||||
* Usage: sudo -u www-data php cli/diagnose_login.php <username> <password>
|
||||
*/
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
$username = $argv[1] ?? '';
|
||||
$password = $argv[2] ?? '';
|
||||
if ($username === '' || $password === '') {
|
||||
fwrite(STDERR, "Usage: php cli/diagnose_login.php <username> <password>\n");
|
||||
fwrite(STDERR, "Run as www-data to match php-fpm: sudo -u www-data php cli/diagnose_login.php ...\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo 'User: ' . (function_exists('posix_getpwuid') ? (posix_getpwuid(posix_geteuid())['name'] ?? '?') : '?') . "\n";
|
||||
echo 'QDB_MASTER_KEY env length: ' . strlen(getenv('QDB_MASTER_KEY') ?: '') . "\n";
|
||||
echo 'Derived key sha256: ' . hash('sha256', get_master_key_bytes()) . "\n\n";
|
||||
|
||||
try {
|
||||
echo "1) qdb_open(read-only)... ";
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
echo "OK\n";
|
||||
|
||||
echo "2) lookup user... ";
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT userID, passwordHash, role, entityID, mustChangePassword FROM users WHERE username = :u"
|
||||
);
|
||||
$stmt->execute([':u' => $username]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo "FAIL — user not found\n";
|
||||
exit(1);
|
||||
}
|
||||
echo "OK (role={$user['role']}, mustChange={$user['mustChangePassword']})\n";
|
||||
|
||||
echo "3) password_verify... ";
|
||||
if (!password_verify($password, $user['passwordHash'])) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo "FAIL — wrong password\n";
|
||||
exit(1);
|
||||
}
|
||||
echo "OK\n";
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
echo "4) token_add (writable save, same as login)... ";
|
||||
$token = bin2hex(random_bytes(32));
|
||||
token_add($token, 120, [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
'temp' => (int)$user['mustChangePassword'] === 1,
|
||||
]);
|
||||
echo "OK\n";
|
||||
|
||||
echo "5) token_get_record... ";
|
||||
$rec = token_get_record($token);
|
||||
if (!$rec) {
|
||||
echo "FAIL — session not readable after save\n";
|
||||
exit(1);
|
||||
}
|
||||
echo "OK\n";
|
||||
|
||||
token_revoke($token);
|
||||
echo "\nAll login steps succeeded. If the website still fails, check nginx routing (curl below).\n";
|
||||
} catch (Throwable $e) {
|
||||
echo "FAIL\n " . $e->getMessage() . "\n";
|
||||
echo " " . $e->getFile() . ':' . $e->getLine() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
80
cli/migrate_sqlite_to_mysql.php
Normal file
80
cli/migrate_sqlite_to_mysql.php
Normal file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-time migration: encrypted SQLite file -> MySQL database.
|
||||
*
|
||||
* Usage:
|
||||
* php cli/migrate_sqlite_to_mysql.php
|
||||
*
|
||||
* Requires QDB_MASTER_KEY (source SQLite) and QDB_MYSQL_* variables (target).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../common.php';
|
||||
qdb_env_override('QDB_DRIVER', 'sqlite');
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
if (!is_file(QDB_PATH)) {
|
||||
fwrite(STDERR, "SQLite database file not found at " . QDB_PATH . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
[$sqlitePdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$tables = $sqlitePdo->query(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$tableData = [];
|
||||
foreach ($tables as $table) {
|
||||
$tableData[$table] = $sqlitePdo->query("SELECT * FROM `{$table}`")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
qdb_reset_driver_cache();
|
||||
qdb_env_override('QDB_DRIVER', 'mysql');
|
||||
|
||||
[$mysqlPdo] = qdb_mysql_open();
|
||||
qdb_mysql_ensure_ready($mysqlPdo);
|
||||
|
||||
qdb_set_foreign_keys($mysqlPdo, false);
|
||||
$mysqlPdo->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($tableData as $table => $rows) {
|
||||
if (!qdb_table_exists($mysqlPdo, $table)) {
|
||||
throw new RuntimeException("MySQL schema is missing source table: {$table}");
|
||||
}
|
||||
$mysqlPdo->exec("DELETE FROM `{$table}`");
|
||||
if (!$rows) {
|
||||
echo "{$table}: 0 rows\n";
|
||||
continue;
|
||||
}
|
||||
$columns = array_keys($rows[0]);
|
||||
$colList = implode(', ', array_map(fn($c) => "`{$c}`", $columns));
|
||||
$placeholders = implode(', ', array_map(fn($c) => ':' . $c, $columns));
|
||||
$insert = $mysqlPdo->prepare("INSERT INTO `{$table}` ({$colList}) VALUES ({$placeholders})");
|
||||
foreach ($rows as $row) {
|
||||
$params = [];
|
||||
foreach ($columns as $column) {
|
||||
$params[':' . $column] = $row[$column];
|
||||
}
|
||||
$insert->execute($params);
|
||||
}
|
||||
echo "{$table}: " . count($rows) . " rows\n";
|
||||
}
|
||||
$mysqlPdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($mysqlPdo->inTransaction()) {
|
||||
$mysqlPdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
qdb_set_foreign_keys($mysqlPdo, true);
|
||||
}
|
||||
|
||||
qdb_set_schema_version($mysqlPdo, QDB_VERSION);
|
||||
|
||||
echo "Migration complete. Set QDB_DRIVER=mysql in .env and restart PHP.\n";
|
||||
2574
common.php
2574
common.php
File diff suppressed because it is too large
Load Diff
26
composer.json
Normal file
26
composer.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "nat-as/questionnaire-server",
|
||||
"description": "Questionnaire API (MySQL or legacy encrypted SQLite)",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"ext-pdo_sqlite": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"test:coverage": "phpunit --coverage-text",
|
||||
"test:ci": "php tests/check-coverage.php 40"
|
||||
}
|
||||
}
|
||||
BIN
composer.phar
Normal file
BIN
composer.phar
Normal file
Binary file not shown.
340
data/app_ui_strings.json
Normal file
340
data/app_ui_strings.json
Normal file
@ -0,0 +1,340 @@
|
||||
{
|
||||
"version": 2,
|
||||
"description": "Berater-visible app UI strings only (excludes dev settings / database tools).",
|
||||
"keys": [
|
||||
"all_clients",
|
||||
"answer",
|
||||
"april",
|
||||
"august",
|
||||
"auth_footer_accounts",
|
||||
"auth_subtitle_change_password",
|
||||
"auth_subtitle_login",
|
||||
"auth_title",
|
||||
"category_green",
|
||||
"category_red",
|
||||
"category_yellow",
|
||||
"cancel",
|
||||
"choose_answer",
|
||||
"choose_more_elements",
|
||||
"client",
|
||||
"client_code",
|
||||
"client_code_exists",
|
||||
"client_note",
|
||||
"client_note_add",
|
||||
"client_note_add_action",
|
||||
"client_note_edit",
|
||||
"client_note_for_client",
|
||||
"client_note_hint",
|
||||
"client_note_pending",
|
||||
"client_note_saved",
|
||||
"client_note_saved_pending",
|
||||
"client_note_title",
|
||||
"client_note_too_long",
|
||||
"coach_code",
|
||||
"confirm_password_hint",
|
||||
"date_after",
|
||||
"date_before",
|
||||
"day",
|
||||
"december",
|
||||
"done",
|
||||
"download",
|
||||
"download_failed_use_offline",
|
||||
"enter_coach_code",
|
||||
"error",
|
||||
"error_invalid_data",
|
||||
"error_not_found",
|
||||
"questionnaire_title",
|
||||
"exit_btn",
|
||||
"extreme_glass",
|
||||
"february",
|
||||
"fill_all_fields",
|
||||
"fill_both_fields",
|
||||
"hours_short",
|
||||
"january",
|
||||
"july",
|
||||
"june",
|
||||
"lay",
|
||||
"little_glass",
|
||||
"consultation_unlock_requires_rhs",
|
||||
"enter_ages_ranges_hint",
|
||||
"questionnaire_unlock_2_rhs",
|
||||
"questionnaire_unlock_3_integration_index",
|
||||
"questionnaire_unlock_4_consultation_results",
|
||||
"locked",
|
||||
"questionnaire_chip_edit",
|
||||
"questionnaire_chip_edit_pending",
|
||||
"login_btn",
|
||||
"login_failed_with_reason",
|
||||
"login_required",
|
||||
"logout",
|
||||
"logout_confirm_action",
|
||||
"logout_confirm_message",
|
||||
"logout_confirm_title",
|
||||
"logout_offline_message",
|
||||
"logout_offline_title",
|
||||
"logout_pending_detail_in_progress",
|
||||
"logout_pending_detail_notes",
|
||||
"logout_pending_detail_questionnaires",
|
||||
"logout_pending_detail_scoring",
|
||||
"logout_pending_message",
|
||||
"logout_pending_title",
|
||||
"march",
|
||||
"may",
|
||||
"never_glass",
|
||||
"minutes_short",
|
||||
"moderate_glass",
|
||||
"month",
|
||||
"much_glass",
|
||||
"new_password_hint",
|
||||
"next_step_completed_body",
|
||||
"next_step_completed_title",
|
||||
"next_step_loading_body",
|
||||
"next_step_loading_title",
|
||||
"next_step_label",
|
||||
"next_step_questionnaire_body",
|
||||
"next_step_questionnaire_title",
|
||||
"next_step_review_body",
|
||||
"next_step_review_title",
|
||||
"next_step_show_questionnaire",
|
||||
"next_step_upload_body",
|
||||
"next_step_upload_title",
|
||||
"no_clients_assigned",
|
||||
"no_profile",
|
||||
"no_questionnaires",
|
||||
"no_questions_available",
|
||||
"questionnaire_missing_options",
|
||||
"none",
|
||||
"not_done",
|
||||
"november",
|
||||
"october",
|
||||
"offline",
|
||||
"other_country",
|
||||
"other_option",
|
||||
"ok",
|
||||
"online",
|
||||
"password_hint",
|
||||
"password_too_short",
|
||||
"passwords_dont_match",
|
||||
"please_client_code",
|
||||
"please_username_password",
|
||||
"process_complete_body",
|
||||
"process_complete_hub_body",
|
||||
"process_complete_ok",
|
||||
"process_complete_set_category",
|
||||
"process_complete_title",
|
||||
"points",
|
||||
"previous",
|
||||
"question",
|
||||
"questionnaire_progress_answered",
|
||||
"questions_filled",
|
||||
"refresh",
|
||||
"review_required_before_continue",
|
||||
"review_scores",
|
||||
"review_scores_agree",
|
||||
"review_scores_calculated_category",
|
||||
"review_scores_coach_category",
|
||||
"review_scores_empty",
|
||||
"review_scores_pending_upload",
|
||||
"review_scores_complete_body",
|
||||
"review_scores_complete_title",
|
||||
"review_scores_done",
|
||||
"review_scores_save_failed",
|
||||
"review_scores_saved",
|
||||
"review_scores_saved_pending",
|
||||
"review_scores_set_category",
|
||||
"review_scores_subtitle",
|
||||
"review_scores_total",
|
||||
"save",
|
||||
"save_password_btn",
|
||||
"select_one_answer",
|
||||
"select_one_answer_per_row",
|
||||
"september",
|
||||
"session_dash",
|
||||
"session_label",
|
||||
"start",
|
||||
"start_upload",
|
||||
"upload",
|
||||
"upload_failed_title",
|
||||
"upload_item_scoring_review",
|
||||
"upload_nothing_pending",
|
||||
"upload_prepare_failed",
|
||||
"upload_scoring_review_summary",
|
||||
"upload_summary",
|
||||
"upload_success_client_notes",
|
||||
"upload_success_message",
|
||||
"upload_success_questionnaires",
|
||||
"upload_success_scoring_reviews",
|
||||
"upload_success_sync_next",
|
||||
"upload_success_title",
|
||||
"username_hint",
|
||||
"year"
|
||||
],
|
||||
"germanDefaults": {
|
||||
"all_clients": "Alle Klienten",
|
||||
"answer": "Antwort",
|
||||
"april": "April",
|
||||
"august": "August",
|
||||
"auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.",
|
||||
"auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.",
|
||||
"auth_subtitle_login": "Melden Sie sich mit Ihrem Berater-Konto an.",
|
||||
"auth_title": "BW Schützt - NAT-AS",
|
||||
"cancel": "Abbrechen",
|
||||
"category_green": "Grün",
|
||||
"category_red": "Rot",
|
||||
"category_yellow": "Gelb",
|
||||
"choose_answer": "Antwort wählen",
|
||||
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
|
||||
"client": "Klient",
|
||||
"client_code": "Klientencode",
|
||||
"client_code_exists": "Dieser Client-Code existiert bereits.",
|
||||
"client_note": "Klient-Notiz",
|
||||
"client_note_add": "Noch keine Notiz für diesen Klienten — tippen zum Hinzufügen",
|
||||
"client_note_add_action": "Hinzufügen",
|
||||
"client_note_edit": "Bearbeiten",
|
||||
"client_note_for_client": "Notiz für {code}",
|
||||
"client_note_hint": "Nur für diesen Klienten · bis zu 200 Zeichen",
|
||||
"client_note_pending": "Upload ausstehend",
|
||||
"client_note_saved": "Notiz für diesen Klienten gespeichert",
|
||||
"client_note_saved_pending": "Notiz für diesen Klienten gespeichert — wird beim Upload gesendet",
|
||||
"client_note_title": "Notiz für diesen Klienten",
|
||||
"client_note_too_long": "Notiz darf höchstens 200 Zeichen haben",
|
||||
"coach_code": "Berater-Code",
|
||||
"confirm_password_hint": "Passwort bestätigen",
|
||||
"date_after": "Das Datum muss nach",
|
||||
"date_before": "Das Datum muss vor",
|
||||
"day": "Tag",
|
||||
"december": "Dezember",
|
||||
"done": "Erledigt",
|
||||
"download": "Herunterladen",
|
||||
"download_failed_use_offline": "Download fehlgeschlagen – arbeite offline mit vorhandener Datenbank",
|
||||
"enter_coach_code": "Bitte geben Sie Ihren Berater-Code ein, um fortzufahren!",
|
||||
"error": "Fehler",
|
||||
"error_invalid_data": "Ungültige Daten",
|
||||
"error_not_found": "Nicht gefunden",
|
||||
"questionnaire_title": "Fragebögen",
|
||||
"exit_btn": "Beenden",
|
||||
"extreme_glass": "Extrem",
|
||||
"february": "Februar",
|
||||
"fill_all_fields": "Bitte alle Felder ausfüllen!",
|
||||
"fill_both_fields": "Bitte Klienten-Code und Berater-Code prüfen.",
|
||||
"hours_short": "h",
|
||||
"january": "Januar",
|
||||
"july": "Juli",
|
||||
"june": "Juni",
|
||||
"lay": "liegen.",
|
||||
"little_glass": "Wenig",
|
||||
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
|
||||
"enter_ages_ranges_hint": "Bitte Alter als Bereich (z. B. 8–12) oder einzelne Jahre (z. B. 6, 9, 13) eingeben",
|
||||
"questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie",
|
||||
"questionnaire_unlock_3_integration_index": "Verfügbar nach Abschluss von Demografie und RHS",
|
||||
"questionnaire_unlock_4_consultation_results": "Verfügbar nach Abschluss von Demografie (Einwilligung unterschrieben), RHS und IPL",
|
||||
"locked": "Gesperrt",
|
||||
"questionnaire_chip_edit": "Bearbeiten",
|
||||
"questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend",
|
||||
"login_btn": "Login",
|
||||
"login_failed_with_reason": "Login fehlgeschlagen: {reason}",
|
||||
"login_required": "Bitte zuerst einloggen",
|
||||
"logout": "Abmelden",
|
||||
"logout_confirm_action": "Abmelden",
|
||||
"logout_confirm_message": "Alle Daten auf diesem Gerät werden gelöscht. Sie müssen sich danach erneut anmelden.",
|
||||
"logout_confirm_title": "Wirklich abmelden?",
|
||||
"logout_offline_message": "Sie sind offline. Stellen Sie eine Internetverbindung her, laden Sie ausstehende Daten hoch und versuchen Sie es dann erneut.",
|
||||
"logout_offline_title": "Abmelden nicht möglich",
|
||||
"logout_pending_detail_in_progress": "{count} begonnene(r) Fragebogen mit lokalen Antworten",
|
||||
"logout_pending_detail_notes": "{count} Notiz(en) warten auf Upload",
|
||||
"logout_pending_detail_questionnaires": "{count} abgeschlossene(r) Fragebogen warten auf Upload",
|
||||
"logout_pending_detail_scoring": "{count} Punkteprüfung(en) warten auf Upload",
|
||||
"logout_pending_message": "Auf diesem Gerät liegen noch nicht hochgeladene Daten vor. Bitte tippen Sie auf „Hochladen“ und warten Sie, bis der Upload abgeschlossen ist. Schließen Sie begonnene Fragebögen zuerst ab.",
|
||||
"logout_pending_title": "Abmelden nicht möglich",
|
||||
"march": "März",
|
||||
"may": "Mai",
|
||||
"never_glass": "Nie",
|
||||
"minutes_short": "m",
|
||||
"moderate_glass": "Mittel",
|
||||
"month": "Monat",
|
||||
"much_glass": "Viel",
|
||||
"new_password_hint": "Neues Passwort",
|
||||
"next_step_completed_body": "Für diesen Klienten sind alle verfügbaren Schritte abgeschlossen.",
|
||||
"next_step_completed_title": "Alles ist abgeschlossen",
|
||||
"next_step_loading_body": "Der nächste sinnvolle Schritt wird vorbereitet.",
|
||||
"next_step_loading_title": "Nächster Schritt",
|
||||
"next_step_label": "Nächster Schritt",
|
||||
"next_step_questionnaire_body": "Wählen Sie unten den nächsten verfügbaren Fragebogen.",
|
||||
"next_step_questionnaire_title": "Nächsten Fragebogen ausfüllen",
|
||||
"next_step_review_body": "Prüfen Sie die berechneten Kategorien vor dem Upload.",
|
||||
"next_step_review_title": "Punkteprüfung abschließen",
|
||||
"next_step_show_questionnaire": "Fragebogen anzeigen",
|
||||
"next_step_upload_body": "Bitte prüfen und hochladen, sobald Sie bereit sind.",
|
||||
"next_step_upload_title": "Daten warten auf Upload",
|
||||
"no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.",
|
||||
"no_profile": "Dieser Klient ist noch nicht Teil der Datenbank",
|
||||
"no_questionnaires": "Keine Fragebögen vorhanden.",
|
||||
"no_questions_available": "Keine Fragen vorhanden.",
|
||||
"questionnaire_missing_options": "Dieser Fragebogen ist unvollständig (fehlende Antwortoptionen). Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"none": "Keine",
|
||||
"not_done": "Nicht erledigt",
|
||||
"november": "November",
|
||||
"october": "Oktober",
|
||||
"offline": "Offline",
|
||||
"other_country": "anderes Land",
|
||||
"other_option": "Sonstiges",
|
||||
"ok": "OK",
|
||||
"online": "Online",
|
||||
"password_hint": "Passwort",
|
||||
"password_too_short": "Mindestens 8 Zeichen, mit Großbuchstabe, Zahl und Sonderzeichen",
|
||||
"passwords_dont_match": "Passwörter stimmen nicht überein",
|
||||
"please_client_code": "Bitte Klienten Code eingeben",
|
||||
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
|
||||
"process_complete_body": "Dieser Klient hat den Prozess abgeschlossen — alle verfügbaren Fragebögen sind erledigt oder eine als abgeschlossen markierte Kategorie wurde gesetzt. Sie können die Kategorie ändern, um weitere Fragebögen freizuschalten.",
|
||||
"process_complete_hub_body": "Alle verfügbaren Schritte für diesen Klienten sind abgeschlossen.",
|
||||
"process_complete_ok": "OK",
|
||||
"process_complete_set_category": "Einstufung einsehen",
|
||||
"process_complete_title": "Prozess abgeschlossen",
|
||||
"points": "Punkte",
|
||||
"previous": "Zurück",
|
||||
"question": "Frage",
|
||||
"questionnaire_progress_answered": "{answered} von {total} beantwortet",
|
||||
"questions_filled": "Antworten",
|
||||
"refresh": "Aktualisieren",
|
||||
"review_required_before_continue": "Bitte schließen Sie zuerst die Punkteprüfung ab, bevor Sie weitere Fragebögen ausfüllen.",
|
||||
"review_scores": "Punkte prüfen",
|
||||
"review_scores_agree": "Berechnete Kategorie bestätigen",
|
||||
"review_scores_calculated_category": "Berechnete Kategorie",
|
||||
"review_scores_coach_category": "Berater-Kategorie",
|
||||
"review_scores_empty": "Noch keine vollständigen Profilwerte zum Prüfen.",
|
||||
"review_scores_pending_upload": "Ausstehend — wird mit dem Upload gesendet",
|
||||
"review_scores_complete_body": "Alle Kategorien sind ausgewählt. Die Prüfung wird beim nächsten Upload gesendet.",
|
||||
"review_scores_complete_title": "Punkteprüfung abgeschlossen",
|
||||
"review_scores_done": "Fertig",
|
||||
"review_scores_save_failed": "Kategorie konnte nicht gespeichert werden.",
|
||||
"review_scores_saved": "Kategorie gespeichert.",
|
||||
"review_scores_saved_pending": "Kategorie gespeichert — wird beim Upload gesendet.",
|
||||
"review_scores_set_category": "Kategorie setzen",
|
||||
"review_scores_subtitle": "Prüfen Sie die berechneten Werte und bestätigen oder setzen Sie die Kategorie.",
|
||||
"review_scores_total": "Gewichtete Summe",
|
||||
"save": "Speichern",
|
||||
"save_password_btn": "Passwort speichern",
|
||||
"select_one_answer": "Bitte wählen Sie eine Antwort aus!",
|
||||
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
|
||||
"september": "September",
|
||||
"session_dash": "Sitzung: —",
|
||||
"session_label": "Sitzung",
|
||||
"start": "Start",
|
||||
"start_upload": "Upload starten?",
|
||||
"upload": "Hochladen",
|
||||
"upload_failed_title": "Upload fehlgeschlagen",
|
||||
"upload_item_scoring_review": "Punkteprüfung: {profile}",
|
||||
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
|
||||
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
|
||||
"upload_scoring_review_summary": "Berater: {band} · Summe {total}",
|
||||
"upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {notes} Notizen · {clients} Klienten",
|
||||
"upload_success_client_notes": "{count} Notiz(en)",
|
||||
"upload_success_message": "Hochladen: {count} erledigt.",
|
||||
"upload_success_questionnaires": "{count} Fragebogen/Fragebögen",
|
||||
"upload_success_scoring_reviews": "{count} Punkteprüfung(en)",
|
||||
"upload_success_sync_next": "Nach diesem Upload startet die Synchronisierung automatisch.",
|
||||
"upload_success_title": "Upload abgeschlossen",
|
||||
"username_hint": "Benutzername",
|
||||
"year": "Jahr"
|
||||
}
|
||||
}
|
||||
190
data/countries_de_sorted.txt
Normal file
190
data/countries_de_sorted.txt
Normal file
@ -0,0 +1,190 @@
|
||||
Ägypten
|
||||
Äquatorialguinea
|
||||
Äthiopien
|
||||
Afghanistan
|
||||
Albanien
|
||||
Algerien
|
||||
Andorra
|
||||
Angola
|
||||
Antigua und Barbuda
|
||||
Argentinien
|
||||
Armenien
|
||||
Aserbaidschan
|
||||
Australien
|
||||
Bahamas
|
||||
Bahrain
|
||||
Bangladesch
|
||||
Barbados
|
||||
Belgien
|
||||
Belize
|
||||
Benin
|
||||
Bhutan
|
||||
Bolivien
|
||||
Bosnien und Herzegowina
|
||||
Botswana
|
||||
Brasilien
|
||||
Brunei
|
||||
Bulgarien
|
||||
Burkina Faso
|
||||
Burundi
|
||||
Cabo Verde
|
||||
Cambodia
|
||||
Chile
|
||||
China
|
||||
Costa Rica
|
||||
Dänemark
|
||||
Deutschland
|
||||
Djibouti
|
||||
Dominica
|
||||
Dominikanische Republik
|
||||
Ecuador
|
||||
El Salvador
|
||||
Eritrea
|
||||
Estland
|
||||
Eswatini
|
||||
Fiji
|
||||
Finnland
|
||||
Frankreich
|
||||
Gabon
|
||||
Gambia
|
||||
Georgien
|
||||
Ghana
|
||||
Grenada
|
||||
Griechenland
|
||||
Guatemala
|
||||
Guinea
|
||||
Guinea-Bissau
|
||||
Guyana
|
||||
Haiti
|
||||
Honduras
|
||||
Indien
|
||||
Indonesien
|
||||
Irak
|
||||
Iran
|
||||
Irland
|
||||
Island
|
||||
Israel
|
||||
Italien
|
||||
Jamaika
|
||||
Japan
|
||||
Jordanien
|
||||
Kamerun
|
||||
Kanada
|
||||
Kasachstan
|
||||
Kenia
|
||||
Kiribati
|
||||
Kolumbien
|
||||
Komoren
|
||||
Kongo (Dem. Rep.)
|
||||
Kongo (Rep.)
|
||||
Korea (Nord)
|
||||
Korea (Süd)
|
||||
Kroatien
|
||||
Kuba
|
||||
Kuwait
|
||||
Kyrgyzstan
|
||||
Laos
|
||||
Latvia
|
||||
Lebanon
|
||||
Lesotho
|
||||
Liberia
|
||||
Libyen
|
||||
Liechtenstein
|
||||
Litauen
|
||||
Luxemburg
|
||||
Madagaskar
|
||||
Malawi
|
||||
Malaysia
|
||||
Maldiven
|
||||
Mali
|
||||
Malta
|
||||
Marokko
|
||||
Marshallinseln
|
||||
Mauritanien
|
||||
Mauritius
|
||||
Mexiko
|
||||
Mikronesien
|
||||
Moldawien
|
||||
Monaco
|
||||
Mongolei
|
||||
Montenegro
|
||||
Mozambique
|
||||
Namibia
|
||||
Nauru
|
||||
Nepal
|
||||
Nicaragua
|
||||
Niger
|
||||
Nigeria
|
||||
Nordmazedonien
|
||||
Norwegen
|
||||
Österreich
|
||||
Oman
|
||||
Pakistan
|
||||
Palau
|
||||
Panama
|
||||
Papua-Neuguinea
|
||||
Paraguay
|
||||
Peru
|
||||
Philippinen
|
||||
Polen
|
||||
Portugal
|
||||
Ruanda
|
||||
Rumänien
|
||||
Russland
|
||||
Salomonen
|
||||
Sambia
|
||||
Samoa
|
||||
San Marino
|
||||
Sao Tome und Principe
|
||||
Saudi-Arabien
|
||||
Schweden
|
||||
Schweiz
|
||||
Senegal
|
||||
Serbien
|
||||
Seychellen
|
||||
Sierra Leone
|
||||
Simbabwe
|
||||
Singapur
|
||||
Slowakei
|
||||
Slowenien
|
||||
Spanien
|
||||
Sri Lanka
|
||||
St. Kitts und Nevis
|
||||
St. Lucia
|
||||
St. Vincent und die Grenadinen
|
||||
Sudan
|
||||
Südafrika
|
||||
Südkorea
|
||||
Südsudan
|
||||
Suriname
|
||||
Syrien
|
||||
São Tomé und Príncipe
|
||||
Tadschikistan
|
||||
Taiwan
|
||||
Tansania
|
||||
Togo
|
||||
Tonga
|
||||
Trinidad und Tobago
|
||||
Tschad
|
||||
Tschechien
|
||||
Türkei
|
||||
Tunisien
|
||||
Turkmenistan
|
||||
Tuvalu
|
||||
Uganda
|
||||
Ukraine
|
||||
Ungarn
|
||||
Uruguay
|
||||
Usbekistan
|
||||
Vanuatu
|
||||
Venezuela
|
||||
Vereinigte Arabische Emirate
|
||||
Vereinigte Staaten
|
||||
Vereinigtes Königreich
|
||||
Vietnam
|
||||
Wallis und Futuna
|
||||
Westjordanland
|
||||
Westsahara
|
||||
Yemen
|
||||
Zentralafrikanische Republik
|
||||
Zypern
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
// /var/www/html/db_download.php
|
||||
require_once __DIR__ . '/db_init.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
$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;
|
||||
560
db_init.php
560
db_init.php
@ -1,102 +1,542 @@
|
||||
<?php
|
||||
// /var/www/html/db_init.php
|
||||
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
|
||||
// Shared helpers for opening, creating, and saving the questionnaire database.
|
||||
require_once __DIR__ . '/common.php';
|
||||
require_once __DIR__ . '/lib/sql_dialect.php';
|
||||
|
||||
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
||||
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
||||
if (defined('QDB_TEST_UPLOADS')) {
|
||||
define('QDB_UPLOADS_DIR', QDB_TEST_UPLOADS);
|
||||
define('QDB_PATH', QDB_UPLOADS_DIR . '/questionnaire_database');
|
||||
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||
} else {
|
||||
define('QDB_UPLOADS_DIR', __DIR__ . '/uploads');
|
||||
define('QDB_PATH', QDB_UPLOADS_DIR . '/questionnaire_database');
|
||||
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||
}
|
||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||
define('QDB_VERSION', 3);
|
||||
define('QDB_VERSION', 14);
|
||||
|
||||
/**
|
||||
* Apply incremental schema fixes. Returns true if the DB was modified.
|
||||
*/
|
||||
function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
||||
$changed = false;
|
||||
|
||||
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
|
||||
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'client')) {
|
||||
if (!qdb_column_exists($pdo, 'client', 'archived')) {
|
||||
$pdo->exec('ALTER TABLE client ADD COLUMN archived INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'client', 'archivedAt')) {
|
||||
$pdo->exec('ALTER TABLE client ADD COLUMN archivedAt INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'questionnaire')) {
|
||||
if (!qdb_column_exists($pdo, 'questionnaire', 'structureRevision')) {
|
||||
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'questionnaire', 'structureChangedAt')) {
|
||||
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureChangedAt INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE questionnaire_structure_snapshot (
|
||||
questionnaireID TEXT NOT NULL,
|
||||
structureRevision INTEGER NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
manifestJson TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (questionnaireID, structureRevision),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'question')) {
|
||||
if (!qdb_column_exists($pdo, 'question', 'retiredAt')) {
|
||||
$pdo->exec('ALTER TABLE question ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'question', 'retiredInRevision')) {
|
||||
$pdo->exec('ALTER TABLE question ADD COLUMN retiredInRevision INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'answer_option')) {
|
||||
if (!qdb_column_exists($pdo, 'answer_option', 'retiredAt')) {
|
||||
$pdo->exec('ALTER TABLE answer_option ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureRevision')) {
|
||||
$pdo->exec('ALTER TABLE questionnaire_submission ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureSnapshotJson')) {
|
||||
$pdo->exec("ALTER TABLE questionnaire_submission ADD COLUMN structureSnapshotJson TEXT NOT NULL DEFAULT '{}'");
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')
|
||||
&& qdb_table_exists($pdo, 'questionnaire')) {
|
||||
require_once __DIR__ . '/lib/questionnaire_structure.php';
|
||||
if (qdb_backfill_structure_snapshots($pdo)) {
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE questionnaire_submission (
|
||||
submissionID TEXT NOT NULL PRIMARY KEY,
|
||||
clientCode TEXT NOT NULL,
|
||||
questionnaireID TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
submittedAt INTEGER NOT NULL,
|
||||
submittedByUserID TEXT NOT NULL DEFAULT '',
|
||||
submittedByRole TEXT NOT NULL DEFAULT '',
|
||||
assignedByCoach TEXT,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
startedAt INTEGER,
|
||||
completedAt INTEGER,
|
||||
sumPoints INTEGER,
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
||||
UNIQUE(clientCode, questionnaireID, version)
|
||||
)
|
||||
");
|
||||
qdb_create_index_if_not_exists($pdo, 'idx_submission_client_qn', 'questionnaire_submission', 'clientCode, questionnaireID');
|
||||
qdb_create_index_if_not_exists($pdo, 'idx_submission_client_time', 'questionnaire_submission', 'clientCode, submittedAt');
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE client_answer_submission (
|
||||
submissionID TEXT NOT NULL,
|
||||
questionID TEXT NOT NULL,
|
||||
answerOptionID TEXT,
|
||||
freeTextValue TEXT,
|
||||
numericValue REAL,
|
||||
answeredAt INTEGER,
|
||||
PRIMARY KEY (submissionID, questionID),
|
||||
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
||||
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'client_followup_note')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE client_followup_note (
|
||||
clientCode TEXT NOT NULL PRIMARY KEY,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updatedByUserID TEXT NOT NULL DEFAULT '',
|
||||
updatedAt INTEGER NOT NULL,
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'session')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE session (
|
||||
token TEXT NOT NULL PRIMARY KEY,
|
||||
userID TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
entityID TEXT NOT NULL DEFAULT '',
|
||||
createdAt INTEGER NOT NULL,
|
||||
expiresAt INTEGER NOT NULL,
|
||||
temp INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'system_setting')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE system_setting (
|
||||
settingKey TEXT NOT NULL PRIMARY KEY,
|
||||
settingValue TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'question_score_rule')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE question_score_rule (
|
||||
ruleID TEXT NOT NULL PRIMARY KEY,
|
||||
questionID TEXT NOT NULL,
|
||||
scopeKey TEXT NOT NULL DEFAULT '',
|
||||
levelKey TEXT NOT NULL,
|
||||
points INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
|
||||
UNIQUE(questionID, scopeKey, levelKey)
|
||||
)
|
||||
");
|
||||
qdb_create_index_if_not_exists($pdo, 'idx_score_rule_question', 'question_score_rule', 'questionID');
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'scoring_profile')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE scoring_profile (
|
||||
profileID TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
isActive INTEGER NOT NULL DEFAULT 1,
|
||||
greenMin INTEGER NOT NULL DEFAULT 0,
|
||||
greenMax INTEGER NOT NULL DEFAULT 12,
|
||||
yellowMin INTEGER NOT NULL DEFAULT 13,
|
||||
yellowMax INTEGER NOT NULL DEFAULT 36,
|
||||
redMin INTEGER NOT NULL DEFAULT 37,
|
||||
processCompleteBands TEXT NOT NULL DEFAULT '[]',
|
||||
createdAt INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'scoring_profile_questionnaire')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE scoring_profile_questionnaire (
|
||||
profileID TEXT NOT NULL,
|
||||
questionnaireID TEXT NOT NULL,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (profileID, questionnaireID),
|
||||
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE client_scoring_profile_result (
|
||||
clientCode TEXT NOT NULL,
|
||||
profileID TEXT NOT NULL,
|
||||
weightedTotal REAL NOT NULL DEFAULT 0,
|
||||
band TEXT NOT NULL DEFAULT '',
|
||||
computedAt INTEGER NOT NULL DEFAULT 0,
|
||||
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
|
||||
coachBand TEXT NOT NULL DEFAULT '',
|
||||
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
|
||||
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (clientCode, profileID),
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
|
||||
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
qdb_create_index_if_not_exists($pdo, 'idx_client_profile_result_profile', 'client_scoring_profile_result', 'profileID');
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
||||
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachBand')) {
|
||||
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachBand TEXT NOT NULL DEFAULT ''");
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedAt')) {
|
||||
$pdo->exec('ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedAt INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedByUserID')) {
|
||||
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedByUserID TEXT NOT NULL DEFAULT ''");
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
require_once __DIR__ . '/lib/submissions.php';
|
||||
if (qdb_backfill_submissions_from_live($pdo)) {
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < 10
|
||||
&& qdb_table_exists($pdo, 'completed_questionnaire')
|
||||
&& qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
||||
require_once __DIR__ . '/lib/scoring.php';
|
||||
if (qdb_recompute_all_questionnaire_scores($pdo) > 0) {
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < 7 && qdb_table_exists($pdo, 'question_score_rule')) {
|
||||
require_once __DIR__ . '/lib/scoring.php';
|
||||
if (qdb_backfill_glass_score_rules($pdo) > 0) {
|
||||
$changed = true;
|
||||
}
|
||||
if (qdb_recompute_all_questionnaire_scores($pdo) > 0) {
|
||||
$changed = true;
|
||||
}
|
||||
if (qdb_seed_default_rhs_scoring_profile($pdo) !== null) {
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < 8 && qdb_table_exists($pdo, 'scoring_profile')) {
|
||||
if (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) {
|
||||
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN greenMin INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'scoring_profile', 'yellowMin')) {
|
||||
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN yellowMin INTEGER NOT NULL DEFAULT 13');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'scoring_profile', 'redMin')) {
|
||||
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN redMin INTEGER NOT NULL DEFAULT 37');
|
||||
$changed = true;
|
||||
}
|
||||
$pdo->exec(
|
||||
'UPDATE scoring_profile SET
|
||||
greenMin = COALESCE(greenMin, 0),
|
||||
yellowMin = greenMax + 1,
|
||||
redMin = yellowMax + 1'
|
||||
);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) {
|
||||
if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) {
|
||||
$pdo->exec("ALTER TABLE scoring_profile ADD COLUMN processCompleteBands TEXT NOT NULL DEFAULT '[]'");
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < 14) {
|
||||
if (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) {
|
||||
$pdo->exec('ALTER TABLE language ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) {
|
||||
$pdo->exec(
|
||||
'CREATE TABLE questionnaire_language_disable (
|
||||
questionnaireID TEXT NOT NULL,
|
||||
languageCode TEXT NOT NULL,
|
||||
PRIMARY KEY (questionnaireID, languageCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
|
||||
)'
|
||||
);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$pdo->exec(
|
||||
'UPDATE questionnaire_submission SET submittedAt = completedAt
|
||||
WHERE completedAt IS NOT NULL AND submittedAt != completedAt'
|
||||
);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$currentVersion = qdb_schema_version($pdo);
|
||||
if ($currentVersion < QDB_VERSION) {
|
||||
qdb_set_foreign_keys($pdo, false);
|
||||
try {
|
||||
qdb_exec_schema_sql($pdo, $schemaSql);
|
||||
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
|
||||
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
qdb_set_schema_version($pdo, QDB_VERSION);
|
||||
} finally {
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
}
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
function qdb_mysql_ensure_ready(PDO $pdo): void {
|
||||
static $ready = false;
|
||||
if ($ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lockName = 'nat_as_schema_migration';
|
||||
$lockStmt = $pdo->prepare('SELECT GET_LOCK(:name, 30)');
|
||||
$lockStmt->execute([':name' => $lockName]);
|
||||
if ((int)$lockStmt->fetchColumn() !== 1) {
|
||||
throw new RuntimeException('Could not acquire MySQL schema migration lock');
|
||||
}
|
||||
|
||||
try {
|
||||
$schemaPath = qdb_schema_file();
|
||||
$schemaSql = file_get_contents($schemaPath);
|
||||
if ($schemaSql === false) {
|
||||
throw new Exception('Could not read MySQL schema');
|
||||
}
|
||||
if (!qdb_table_exists($pdo, 'users') || qdb_schema_version($pdo) < QDB_VERSION) {
|
||||
qdb_exec_schema_file($pdo, $schemaPath);
|
||||
}
|
||||
qdb_apply_migrations($pdo, $schemaSql);
|
||||
$ready = true;
|
||||
} finally {
|
||||
$releaseStmt = $pdo->prepare('SELECT RELEASE_LOCK(:name)');
|
||||
$releaseStmt->execute([':name' => $lockName]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sleepMicros = 75000): void {
|
||||
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
||||
if (flock($lockFp, LOCK_EX | LOCK_NB)) {
|
||||
return;
|
||||
}
|
||||
if ($attempt < $maxAttempts - 1) {
|
||||
usleep($sleepMicros);
|
||||
}
|
||||
}
|
||||
throw new Exception('Could not acquire lock');
|
||||
}
|
||||
|
||||
function qdb_open_writable_lock() {
|
||||
$lockFp = fopen(QDB_LOCK, 'c');
|
||||
if ($lockFp === false) {
|
||||
throw new Exception('Could not open lock file');
|
||||
}
|
||||
qdb_flock_exclusive_with_retry($lockFp);
|
||||
return $lockFp;
|
||||
}
|
||||
|
||||
function qdb_acquire_lock() {
|
||||
return qdb_open_writable_lock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the master DB file into a temp SQLite file, or create a fresh one
|
||||
* from schema.sql if no DB exists yet.
|
||||
*
|
||||
* Returns [PDO $pdo, string $tmpPath, resource|null $lockFp].
|
||||
* If $writable is true, an exclusive lock is held -- caller MUST call
|
||||
* qdb_save() or qdb_discard() afterwards.
|
||||
*/
|
||||
function qdb_open(bool $writable = false): array {
|
||||
if (qdb_is_mysql()) {
|
||||
[$pdo] = qdb_mysql_open();
|
||||
qdb_mysql_ensure_ready($pdo);
|
||||
return [$pdo, '', null];
|
||||
}
|
||||
|
||||
if (!$writable) {
|
||||
require_once __DIR__ . '/lib/read_db_cache.php';
|
||||
return qdb_read_db_open();
|
||||
}
|
||||
|
||||
$dbPath = QDB_PATH;
|
||||
|
||||
if (!is_dir(dirname($dbPath))) {
|
||||
if (!mkdir(dirname($dbPath), 0755, true) && !is_dir(dirname($dbPath))) {
|
||||
throw new Exception("Could not create uploads directory");
|
||||
throw new Exception('Could not create uploads directory');
|
||||
}
|
||||
}
|
||||
|
||||
$lockFp = null;
|
||||
if ($writable) {
|
||||
$lockFp = fopen(QDB_LOCK, 'c');
|
||||
if ($lockFp === false) throw new Exception("Could not open lock file");
|
||||
if (!flock($lockFp, LOCK_EX)) {
|
||||
fclose($lockFp);
|
||||
throw new Exception("Could not acquire lock");
|
||||
}
|
||||
}
|
||||
$lockFp = qdb_open_writable_lock();
|
||||
|
||||
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_');
|
||||
$masterKey = get_master_key_bytes();
|
||||
|
||||
$sql = file_get_contents(QDB_SCHEMA);
|
||||
if ($sql === false) {
|
||||
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
|
||||
throw new Exception("Could not read schema.sql");
|
||||
flock($lockFp, LOCK_UN);
|
||||
fclose($lockFp);
|
||||
throw new Exception('Could not read schema.sql');
|
||||
}
|
||||
|
||||
if (file_exists($dbPath) && is_file($dbPath)) {
|
||||
$storedEnc = @file_get_contents($dbPath);
|
||||
if ($storedEnc === false) {
|
||||
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
|
||||
throw new Exception("Could not read stored DB");
|
||||
flock($lockFp, LOCK_UN);
|
||||
fclose($lockFp);
|
||||
throw new Exception('Could not read stored DB');
|
||||
}
|
||||
try {
|
||||
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
|
||||
} catch (Throwable $e) {
|
||||
error_log(sprintf(
|
||||
'qdb_open decrypt failed: path=%s realpath=%s enc_bytes=%d key_sha=%s sapi=%s env_readable=%s common=%s',
|
||||
$dbPath,
|
||||
realpath($dbPath) ?: 'none',
|
||||
strlen($storedEnc),
|
||||
hash('sha256', $masterKey),
|
||||
PHP_SAPI,
|
||||
is_readable(__DIR__ . '/.env') ? 'yes' : 'no',
|
||||
realpath(__DIR__ . '/common.php') ?: __DIR__ . '/common.php'
|
||||
));
|
||||
throw $e;
|
||||
}
|
||||
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
|
||||
if (file_put_contents($tmpDb, $decrypted) === false) {
|
||||
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
|
||||
throw new Exception("Could not write temp DB");
|
||||
flock($lockFp, LOCK_UN);
|
||||
fclose($lockFp);
|
||||
throw new Exception('Could not write temp DB');
|
||||
}
|
||||
} else {
|
||||
$pdo = new PDO("sqlite:$tmpDb");
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->exec($sql);
|
||||
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
|
||||
qdb_set_schema_version($pdo, QDB_VERSION);
|
||||
$pdo = null;
|
||||
}
|
||||
|
||||
$pdo = new PDO("sqlite:$tmpDb");
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
|
||||
// Schema upgrade: apply CREATE TABLE IF NOT EXISTS for any missing tables
|
||||
$currentVersion = (int)$pdo->query("PRAGMA user_version")->fetchColumn();
|
||||
if ($currentVersion < QDB_VERSION) {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$pdo->exec($sql);
|
||||
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
}
|
||||
qdb_apply_migrations($pdo, $sql);
|
||||
|
||||
return [$pdo, $tmpDb, $lockFp];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the temp DB and atomically write it back to the master path.
|
||||
* Releases the lock.
|
||||
*/
|
||||
function qdb_save(string $tmpDb, $lockFp): void {
|
||||
if (qdb_is_mysql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$masterKey = get_master_key_bytes();
|
||||
try {
|
||||
$ck = new PDO('sqlite:' . $tmpDb);
|
||||
$ck->exec('PRAGMA wal_checkpoint(FULL);');
|
||||
} catch (Throwable $e) {
|
||||
// best-effort before reading plaintext bytes
|
||||
}
|
||||
$plainDb = file_get_contents($tmpDb);
|
||||
if ($plainDb === false) throw new Exception("Could not read temp DB for save");
|
||||
if ($plainDb === false) {
|
||||
throw new Exception('Could not read temp DB for save');
|
||||
}
|
||||
$enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey);
|
||||
|
||||
$dbPath = QDB_PATH;
|
||||
$tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_');
|
||||
if (file_put_contents($tmpEncrypted, $enc) === false) {
|
||||
throw new Exception("Could not write encrypted DB");
|
||||
throw new Exception('Could not write encrypted DB');
|
||||
}
|
||||
if (!@rename($tmpEncrypted, $dbPath)) {
|
||||
if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) {
|
||||
throw new Exception("Could not save encrypted DB (rename/copy failed)");
|
||||
throw new Exception('Could not save encrypted DB (rename/copy failed)');
|
||||
}
|
||||
}
|
||||
@chmod($dbPath, 0644);
|
||||
@ -106,15 +546,41 @@ function qdb_save(string $tmpDb, $lockFp): void {
|
||||
flock($lockFp, LOCK_UN);
|
||||
fclose($lockFp);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/lib/read_db_cache.php';
|
||||
qdb_read_cache_invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard changes -- clean up temp file and release lock without saving.
|
||||
*/
|
||||
function qdb_discard(string $tmpDb, $lockFp): void {
|
||||
if (qdb_is_mysql()) {
|
||||
return;
|
||||
}
|
||||
if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) {
|
||||
return;
|
||||
}
|
||||
@unlink($tmpDb);
|
||||
if ($lockFp) {
|
||||
@flock($lockFp, LOCK_UN);
|
||||
@fclose($lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0: PDO, 1: string, 2: resource|null} */
|
||||
function qdb_open_read_or_fail(): array {
|
||||
try {
|
||||
return qdb_open(false);
|
||||
} catch (Throwable $e) {
|
||||
require_once __DIR__ . '/lib/errors.php';
|
||||
qdb_emit_api_error($e, 'Database read');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0: PDO, 1: string, 2: resource|null} */
|
||||
function qdb_open_write_or_fail(): array {
|
||||
try {
|
||||
return qdb_open(true);
|
||||
} catch (Throwable $e) {
|
||||
require_once __DIR__ . '/lib/errors.php';
|
||||
qdb_emit_api_error($e, 'Database write');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
$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,141 +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();
|
||||
|
||||
$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 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>
|
||||
table{border-collapse:collapse;width:100%;}
|
||||
th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top;}
|
||||
thead th{background:#f7f7f7;position:sticky;top:0;z-index:1;}
|
||||
thead tr:nth-child(2) th{background:#fafafa;}
|
||||
.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) {
|
||||
$lbl = $labels[$id] ?? '';
|
||||
echo '<th>'.htmlspecialchars($lbl).'</th>';
|
||||
}
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
foreach ($clients as $client) {
|
||||
echo '<tr 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;
|
||||
@ -31,7 +31,34 @@ Use the front-controller routes under `/api/...`. The clean route `/api/app_ques
|
||||
}
|
||||
```
|
||||
|
||||
Some low-level auth failures can still return a legacy body such as `{"error":"Missing Bearer token"}` or `{"error":"Invalid or expired token"}`.
|
||||
Auth and permission failures use the same envelope, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "Invalid or expired token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Submit validation failures (`POST /api/app_questionnaires`) return `code` `VALIDATION_FAILED` with a `details.errors` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"message": "Some answers are invalid or missing",
|
||||
"details": {
|
||||
"errors": [
|
||||
{ "questionID": "q3", "code": "MISSING_ANSWER", "message": "Required question is not answered" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
@ -50,6 +77,49 @@ Request:
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"token": "64-character-hex-token",
|
||||
"user": "coach1",
|
||||
"role": "coach",
|
||||
"clients": [
|
||||
{ "clientCode": "CLIENT-001" },
|
||||
{ "clientCode": "CLIENT-002" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `coach` accounts, `clients` lists client codes currently assigned to that coach (`client.coachID`). Other roles omit this field or return an empty array.
|
||||
|
||||
If the account must change its password first, the response contains `mustChangePassword: true` and a temporary token. That temporary token cannot access questionnaire endpoints until the password is changed.
|
||||
|
||||
### Change password
|
||||
|
||||
`POST /api/auth/change-password`
|
||||
|
||||
Headers:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
The token may be a normal session token or the temporary token returned when `mustChangePassword` is true.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "coach1",
|
||||
"old_password": "temporary-or-current-password",
|
||||
"new_password": "new-secret"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
@ -61,7 +131,16 @@ Success:
|
||||
}
|
||||
```
|
||||
|
||||
If the account must change its password first, the response contains `mustChangePassword: true` and a temporary token. That temporary token cannot access questionnaire endpoints until the password is changed.
|
||||
The server revokes the previous token and returns a new full session token. `new_password` must be at least 6 characters.
|
||||
|
||||
Common failures:
|
||||
|
||||
- `400 MISSING_FIELDS`: required fields missing.
|
||||
- `400 PASSWORD_TOO_SHORT`: new password too short.
|
||||
- `401 INVALID_CREDENTIALS`: old password incorrect.
|
||||
- `403`: invalid, expired, or mismatched token.
|
||||
|
||||
There is no self-service signup endpoint. Coach accounts are created on the web by administrators.
|
||||
|
||||
## Fetch Questionnaires
|
||||
|
||||
@ -97,6 +176,7 @@ Fields:
|
||||
- `name`: display/admin name.
|
||||
- `showPoints`: whether the app may show the points result.
|
||||
- `condition`: JSON condition object. Empty conditions are returned as `{}`.
|
||||
- `categoryKey` (optional): grouping key for the opening screen. The app resolves the section title as `questionnaire_group_<categoryKey>` (global app UI strings). Keys in use appear automatically on the website **Translations** page; default German text may come from `data/app_ui_strings.json` (`germanDefaults`). Unused translation rows can be removed from the questionnaires dashboard.
|
||||
|
||||
Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`.
|
||||
|
||||
@ -151,10 +231,62 @@ Question fields:
|
||||
- `options[].key`: answer option key. Send this back as `answerOptionKey` during upload.
|
||||
- `options[].nextQuestionId`: optional conditional navigation target.
|
||||
- `pointsMap`: optional map from answer option key to point value.
|
||||
- `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `minSelection`: optional layout-specific config fields.
|
||||
- `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `precision`, `dateRange`, `minSelection`: optional layout-specific config fields.
|
||||
- `dateRange` (date_spinner): `"past"` (default) or `"future"` — which years/dates the app offers.
|
||||
|
||||
Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received.
|
||||
|
||||
## Download Client Answers (restore on device)
|
||||
|
||||
`GET /api/app_questionnaires?clientCode=<code>&answers=1`
|
||||
|
||||
Allowed roles: `coach`, `supervisor`, `admin` (coach must be assigned to the client).
|
||||
|
||||
Headers:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Success (encrypted envelope, same as `?clients=1`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"encrypted": true,
|
||||
"payload": { "...": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Decrypted `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCode": "K008",
|
||||
"questionnaires": [
|
||||
{
|
||||
"questionnaireID": "questionnaire_2_rhs",
|
||||
"completedAt": 1710000000,
|
||||
"sumPoints": 12,
|
||||
"answers": [
|
||||
{ "questionID": "q1", "answerOptionKey": "consent_signed" },
|
||||
{ "questionID": "languages_spoken", "answerOptionKey": "language_arabic" },
|
||||
{ "questionID": "languages_spoken", "answerOptionKey": "language_english" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Returns all `completed_questionnaire` rows for the client.
|
||||
- `answers` mirrors the POST submit shape (short `questionID`, glass-scale symptoms as separate entries, multi-checkbox as multiple rows with the same `questionID`).
|
||||
- On the server, multi-checkbox answers are stored in one `client_answer` row (`freeTextValue` JSON array); export expands them for the app importer.
|
||||
- Use on **client load** to restore answers after upload or on a fresh install.
|
||||
|
||||
## Fetch Assigned Clients
|
||||
|
||||
`GET /api/app_questionnaires?clients=1`
|
||||
@ -178,6 +310,7 @@ Success:
|
||||
"clients": [
|
||||
{
|
||||
"clientCode": "CLIENT-001",
|
||||
"note": "Call back next week",
|
||||
"completedQuestionnaires": [
|
||||
{
|
||||
"questionnaireID": "questionnaire_1_demographic_information",
|
||||
@ -188,6 +321,7 @@ Success:
|
||||
},
|
||||
{
|
||||
"clientCode": "CLIENT-002",
|
||||
"note": "",
|
||||
"completedQuestionnaires": []
|
||||
}
|
||||
]
|
||||
@ -199,6 +333,7 @@ Fields:
|
||||
|
||||
- `clients`: array of client objects assigned to the coach, ordered alphabetically by `clientCode`.
|
||||
- `clients[].clientCode`: the client's code string.
|
||||
- `clients[].note`: counselor follow-up note for this client (max 200 characters; empty string when none).
|
||||
- `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none.
|
||||
- `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint.
|
||||
- `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire.
|
||||
@ -213,6 +348,8 @@ Common failures:
|
||||
|
||||
`GET /api/app_questionnaires?translations=1`
|
||||
|
||||
**Authentication:** not required. The Android app calls this on the login screen before the coach signs in. All other `app_questionnaires` routes still require `Authorization: Bearer <token>`.
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
@ -235,32 +372,74 @@ Success:
|
||||
}
|
||||
```
|
||||
|
||||
The translation map is grouped by language code. Each language contains a flat key-value map.
|
||||
The translation map is grouped by language code. Each language contains a flat key-value map of **app UI strings only** (screens, toasts, auth labels, etc.). Questionnaire question/option text is **not** included here.
|
||||
|
||||
Translation keys can come from:
|
||||
|
||||
- question default text, exposed as `question`
|
||||
- answer option default text, exposed as `options[].key`
|
||||
- general string keys from `string_translation`, such as hints, labels, symptoms, and text keys
|
||||
|
||||
Android lookup process:
|
||||
Android lookup for app UI:
|
||||
|
||||
1. Load `translations[activeLanguage]`.
|
||||
2. For each questionnaire string, look up the string value as a key.
|
||||
3. If a translation is missing, fall back to the raw key from the questionnaire JSON.
|
||||
2. Look up the string key (e.g. `save`, `client_code`).
|
||||
3. If missing, fall back to embedded defaults in the app.
|
||||
|
||||
## Questionnaire detail translations
|
||||
|
||||
`GET /api/app_questionnaires?id=<questionnaireID>` includes a sibling `translations` object with the same shape (language code → key → text), scoped to that questionnaire only:
|
||||
|
||||
- question default text (`question` field values)
|
||||
- answer option default text (`options[].key`)
|
||||
- questionnaire config string keys (`textKey`, hints, symptoms, etc.), excluding global app UI catalog keys
|
||||
|
||||
Android should apply this map while a questionnaire is open (overlay on top of app UI translations), then clear it when leaving the questionnaire.
|
||||
|
||||
## Recommended Android Sync Flow
|
||||
|
||||
1. Login with `/api/auth/login` and store the returned token securely.
|
||||
2. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
|
||||
3. Fetch translations with `GET /api/app_questionnaires?translations=1`.
|
||||
4. Fetch the active questionnaire list with `GET /api/app_questionnaires`.
|
||||
5. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>`.
|
||||
6. Cache the client list, translations, the questionnaire list, and questionnaire details locally for offline use.
|
||||
7. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires`.
|
||||
1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token) and cache locally.
|
||||
2. Login with `/api/auth/login` and store the returned token securely (EncryptedSharedPreferences on Android).
|
||||
3. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
|
||||
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` 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.
|
||||
|
||||
## Android local data security
|
||||
|
||||
The Android app encrypts sensitive data **at rest on the device**:
|
||||
|
||||
- **Answer text** and **client codes** in the local Room database use AES-256-GCM with keys in Android Keystore (ciphertext on disk; plaintext only inside the running app).
|
||||
- **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).
|
||||
|
||||
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.
|
||||
|
||||
Legacy full-database download endpoints (`downloadFull.php`, encrypted SQLite master file) are **not** used by the current Android app.
|
||||
|
||||
## Upload Questionnaire Data
|
||||
|
||||
`POST /api/app_questionnaires`
|
||||
@ -269,6 +448,24 @@ Allowed roles: `admin`, `supervisor`, `coach`.
|
||||
|
||||
The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client.
|
||||
|
||||
### Set client note
|
||||
|
||||
Encrypted body action (coach only):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setClientNote",
|
||||
"clientCode": "CLIENT-001",
|
||||
"note": "Call back next week"
|
||||
}
|
||||
```
|
||||
|
||||
- `note` is optional; omit or send `""` to clear. Max **200** characters after sanitization (same free-text rules as interview answers).
|
||||
- Response `data` is an encrypted envelope containing `{ "clientCode", "note" }`.
|
||||
- Notes are stored in `client_followup_note` (shared with the web Insights follow-up list).
|
||||
|
||||
### Submit questionnaire answers
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
@ -320,8 +517,9 @@ Current upload behavior:
|
||||
|
||||
- Unknown or empty `questionID` values are skipped.
|
||||
- Unknown `answerOptionKey` values are stored as no selected option for that question.
|
||||
- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer.
|
||||
- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum.
|
||||
- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer in the **current** snapshot.
|
||||
- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum (supports correction / re-edit workflows).
|
||||
- Each successful POST also appends a **versioned submission** (`questionnaire_submission` + `client_answer_submission`) so the web dashboard can export all upload history. The app still reads only the current snapshot via `GET ?clientCode=…&answers=1`.
|
||||
- `sumPoints` is calculated from recognized `answerOptionKey` values.
|
||||
- The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question.
|
||||
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
// /var/www/html/downloadFull.php
|
||||
require_once __DIR__ . '/db_init.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$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;
|
||||
21
handlers/activity_log.php
Normal file
21
handlers/activity_log.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Admin: read API activity log (app sync + data changes only).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/api_log.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin'], $tokenRec);
|
||||
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
$date = trim((string)($_GET['date'] ?? date('Y-m-d')));
|
||||
$activity = trim((string)($_GET['activity'] ?? ''));
|
||||
$limit = (int)($_GET['limit'] ?? 200);
|
||||
$errorsOnly = !empty($_GET['errorsOnly']) || $activity === 'errors';
|
||||
|
||||
json_success(qdb_api_log_read_entries($date, $activity, $limit, $errorsOnly));
|
||||
51
handlers/analytics.php
Normal file
51
handlers/analytics.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
require_once __DIR__ . '/../lib/analytics.php';
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
if (!empty($_GET['overview'])) {
|
||||
$data = qdb_analytics_overview($pdo, $tokenRec);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success($data);
|
||||
}
|
||||
|
||||
if (!empty($_GET['staleClients'])) {
|
||||
$clients = qdb_analytics_stale_clients($pdo, $tokenRec);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['clients' => $clients]);
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('BAD_REQUEST', 'Unknown query', 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Analytics', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
$body = read_json_body();
|
||||
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||
$note = qdb_normalize_client_followup_note($body['note'] ?? '');
|
||||
|
||||
try {
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['clientCode' => $clientCode, 'note' => $note]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save follow-up note', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
@ -10,64 +12,80 @@ case 'GET':
|
||||
if (!$qID) {
|
||||
json_error('MISSING_PARAM', 'questionID query param required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex');
|
||||
$stmt->execute([':qid' => $qID]);
|
||||
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($options as &$o) {
|
||||
$o['points'] = (int)$o['points'];
|
||||
$o['orderIndex'] = (int)$o['orderIndex'];
|
||||
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
|
||||
$tr->execute([':id' => $o['answerOptionID']]);
|
||||
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$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'];
|
||||
$o['optionKey'] = qdb_option_key($o);
|
||||
$o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']);
|
||||
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
|
||||
$tr->execute([':id' => $o['answerOptionID']]);
|
||||
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
unset($o);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['answerOptions' => $options]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load answer options', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
unset($o);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['answerOptions' => $options]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
if (empty($body['questionID']) || !isset($body['defaultText'])) {
|
||||
json_error('MISSING_FIELDS', 'questionID and defaultText required', 400);
|
||||
if (empty($body['questionID']) || empty($body['optionKey']) || !isset($body['defaultText'])) {
|
||||
json_error('MISSING_FIELDS', 'questionID, optionKey, and defaultText (German label) required', 400);
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$qID = $body['questionID'];
|
||||
$optKey = qdb_validate_stable_key((string)$body['optionKey'], 'Option key');
|
||||
$text = trim($body['defaultText']);
|
||||
qdb_require_non_empty_german($text, 'German label');
|
||||
$points = (int)($body['points'] ?? 0);
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$nextQ = trim($body['nextQuestionId'] ?? '');
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$chk = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id');
|
||||
$chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id');
|
||||
$chk->execute([':id' => $qID]);
|
||||
if (!$chk->fetch()) {
|
||||
$qnID = $chk->fetchColumn();
|
||||
if (!$qnID) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Question not found', 404);
|
||||
}
|
||||
$qnID = (string)$qnID;
|
||||
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();
|
||||
}
|
||||
qdb_assert_unique_option_key($pdo, $qID, $optKey);
|
||||
$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]);
|
||||
->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
|
||||
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
|
||||
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_added');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['answerOption' => [
|
||||
json_success([
|
||||
'structureRevision' => $newRev,
|
||||
'answerOption' => [
|
||||
'answerOptionID' => $id,
|
||||
'questionID' => $qID,
|
||||
'defaultText' => $text,
|
||||
'optionKey' => $optKey,
|
||||
'defaultText' => $optKey,
|
||||
'labelGerman' => $text,
|
||||
'points' => $points,
|
||||
'orderIndex' => $order,
|
||||
'nextQuestionId' => $nextQ,
|
||||
'translations' => [],
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -79,7 +97,7 @@ case 'PUT':
|
||||
}
|
||||
$id = $body['answerOptionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$existing = $pdo->prepare('SELECT * FROM answer_option WHERE answerOptionID = :id');
|
||||
$existing->execute([':id' => $id]);
|
||||
@ -88,26 +106,46 @@ case 'PUT':
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Answer option not found', 404);
|
||||
}
|
||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||
$optKey = isset($body['optionKey'])
|
||||
? qdb_validate_stable_key((string)$body['optionKey'], 'Option key')
|
||||
: qdb_option_key($row);
|
||||
if ($optKey === '') {
|
||||
json_error('MISSING_FIELDS', 'optionKey is required', 400);
|
||||
}
|
||||
$labelGerman = trim($body['defaultText'] ?? $body['labelGerman'] ?? '');
|
||||
if ($labelGerman === '') {
|
||||
$labelGerman = qdb_option_german_label($pdo, $id, $row['defaultText']);
|
||||
}
|
||||
qdb_require_non_empty_german($labelGerman, 'German label');
|
||||
qdb_assert_unique_option_key($pdo, $row['questionID'], $optKey, $id);
|
||||
$points = (int)($body['points'] ?? $row['points']);
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
|
||||
|
||||
$keyChanged = $optKey !== qdb_option_key($row);
|
||||
$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]);
|
||||
->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
|
||||
qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman);
|
||||
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$row['questionID']);
|
||||
$newRev = null;
|
||||
if ($keyChanged && $qnID !== null) {
|
||||
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_key_changed');
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['answerOption' => [
|
||||
json_success([
|
||||
'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1),
|
||||
'answerOption' => [
|
||||
'answerOptionID' => $id,
|
||||
'questionID' => $row['questionID'],
|
||||
'defaultText' => $text,
|
||||
'optionKey' => $optKey,
|
||||
'defaultText' => $optKey,
|
||||
'labelGerman' => $labelGerman,
|
||||
'points' => $points,
|
||||
'orderIndex' => $order,
|
||||
'nextQuestionId' => $nextQ,
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -119,24 +157,39 @@ case 'DELETE':
|
||||
}
|
||||
$id = $body['answerOptionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
|
||||
$pdo->prepare('UPDATE client_answer SET answerOptionID = NULL WHERE answerOptionID = :id')->execute([':id' => $id]);
|
||||
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
|
||||
$pdo->commit();
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => true]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
$existing = $pdo->prepare('SELECT questionID FROM answer_option WHERE answerOptionID = :id');
|
||||
$existing->execute([':id' => $id]);
|
||||
$qid = $existing->fetchColumn();
|
||||
if (!$qid) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Answer option not found', 404);
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$qid);
|
||||
if ($qnID === null) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Question not found', 404);
|
||||
}
|
||||
$pdo->beginTransaction();
|
||||
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_removed');
|
||||
if (qdb_option_has_client_data($pdo, $id)) {
|
||||
$pdo->prepare('UPDATE answer_option SET retiredAt = :ts WHERE answerOptionID = :id')
|
||||
->execute([':ts' => time(), ':id' => $id]);
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => $newRev]);
|
||||
break;
|
||||
}
|
||||
qdb_set_foreign_keys($pdo, false);
|
||||
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
|
||||
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -146,7 +199,7 @@ case 'PATCH':
|
||||
if (empty($body['questionID']) || !is_array($body['order'] ?? null)) {
|
||||
json_error('MISSING_FIELDS', 'questionID and order[] required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$stmt = $pdo->prepare('UPDATE answer_option SET orderIndex = :o WHERE answerOptionID = :id AND questionID = :qid');
|
||||
foreach ($body['order'] as $idx => $aoid) {
|
||||
@ -155,9 +208,7 @@ case 'PATCH':
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['reordered' => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -3,17 +3,110 @@
|
||||
* Android app API endpoint.
|
||||
* GET (no params) -> ordered questionnaire list with conditions
|
||||
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
|
||||
* GET ?translations=1 -> all translations merged across all tables
|
||||
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
|
||||
* GET ?clients=1 -> list of clients assigned to the authenticated coach
|
||||
* POST -> submit interview answers for a client (coach / supervisor / admin)
|
||||
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
|
||||
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
|
||||
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
|
||||
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
|
||||
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted)
|
||||
* POST action=setClientNote -> upsert counselor note for an assigned client (max 200 chars, encrypted)
|
||||
* POST -> submit interview answers for a client (coach / supervisor / admin).
|
||||
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
|
||||
* 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).
|
||||
if ($method === 'GET' && !empty($_GET['translations'])) {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
json_success([
|
||||
'translations' => qdb_build_app_translations_map($pdo),
|
||||
'availableLanguages' => qdb_available_language_codes($pdo),
|
||||
]);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
if ($method === 'POST') {
|
||||
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
|
||||
|
||||
$body = read_json_body();
|
||||
$body = read_encrypted_json_body($tokenRec['_token']);
|
||||
|
||||
if (($body['action'] ?? '') === 'coachScoringReview') {
|
||||
require_fields($body, ['clientCode', 'profileID', 'coachBand']);
|
||||
$clientCode = trim((string)$body['clientCode']);
|
||||
$profileID = trim((string)$body['profileID']);
|
||||
$coachBand = trim((string)($body['coachBand'] ?? ''));
|
||||
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
|
||||
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
|
||||
|
||||
try {
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$clStmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||
if (!$clStmt->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
|
||||
}
|
||||
|
||||
$profile = qdb_save_coach_scoring_review(
|
||||
$pdo,
|
||||
$clientCode,
|
||||
$profileID,
|
||||
$coachBand,
|
||||
(string)($tokenRec['userID'] ?? ''),
|
||||
$weightedTotal,
|
||||
$calculatedBand !== '' ? $calculatedBand : null,
|
||||
);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['profile' => $profile]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (RuntimeException $e) {
|
||||
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||
json_error('NOT_FOUND', $e->getMessage(), 404);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Coach scoring review', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (($body['action'] ?? '') === 'setClientNote') {
|
||||
require_role(['coach'], $tokenRec);
|
||||
require_fields($body, ['clientCode']);
|
||||
$clientCode = trim((string)$body['clientCode']);
|
||||
require_once __DIR__ . '/../lib/analytics.php';
|
||||
$note = qdb_normalize_client_followup_note($body['note'] ?? '');
|
||||
|
||||
try {
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success_sensitive(
|
||||
['clientCode' => $clientCode, 'note' => $note],
|
||||
$tokenRec['_token']
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save client note', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
|
||||
|
||||
$qnID = trim($body['questionnaireID']);
|
||||
@ -28,15 +121,27 @@ if ($method === 'POST') {
|
||||
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
// Verify questionnaire exists
|
||||
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$qnStmt = $pdo->prepare(
|
||||
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
|
||||
FROM questionnaire WHERE questionnaireID = :id'
|
||||
);
|
||||
$qnStmt->execute([':id' => $qnID]);
|
||||
if (!$qnStmt->fetch()) {
|
||||
$qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$qnRow) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||
}
|
||||
$currentRev = max(1, (int)$qnRow['structureRevision']);
|
||||
$submitRev = max(1, (int)($body['structureRevision'] ?? 1));
|
||||
if ($submitRev > $currentRev) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('STRUCTURE_REVISION_NEWER', 'App structure revision is newer than server', 400);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$clStmt = $pdo->prepare(
|
||||
@ -53,63 +158,122 @@ if ($method === 'POST') {
|
||||
? ($tokenRec['entityID'] ?? '')
|
||||
: ($clientRow['coachID'] ?? '');
|
||||
|
||||
// Load all questions for this questionnaire: full questionID keyed by short ID
|
||||
$qRows = $pdo->prepare("SELECT questionID FROM question WHERE questionnaireID = :qn");
|
||||
$qRows->execute([':qn' => $qnID]);
|
||||
$shortIdMap = []; // shortId -> fullQuestionID
|
||||
foreach ($qRows->fetchAll(PDO::FETCH_COLUMN) as $fullId) {
|
||||
$parts = explode('__', $fullId);
|
||||
$shortIdMap[end($parts)] = $fullId;
|
||||
$isLegacySubmit = $submitRev < $currentRev;
|
||||
if ($isLegacySubmit) {
|
||||
$submitManifest = qdb_load_structure_manifest($pdo, $qnID, $submitRev);
|
||||
if ($submitManifest === null) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('STRUCTURE_REVISION_UNKNOWN', 'Unknown structure revision', 400);
|
||||
}
|
||||
} else {
|
||||
$submitManifest = qdb_build_structure_manifest($pdo, $qnID, false);
|
||||
$submitManifest['structureRevision'] = $currentRev;
|
||||
}
|
||||
|
||||
// Load all answer options for this questionnaire: keyed by [questionID][defaultText]
|
||||
$aoRows = $pdo->prepare("
|
||||
SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn
|
||||
");
|
||||
$aoRows->execute([':qn' => $qnID]);
|
||||
$optionMap = []; // fullQuestionID -> [defaultText -> {answerOptionID, points}]
|
||||
foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionMap[$ao['questionID']][$ao['defaultText']] = [
|
||||
'answerOptionID' => $ao['answerOptionID'],
|
||||
'points' => (int)$ao['points'],
|
||||
];
|
||||
$maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest);
|
||||
$shortIdMap = $maps['shortIdMap'];
|
||||
$shortIdToType = $maps['shortIdToType'];
|
||||
$optionMap = $maps['optionMap'];
|
||||
$symptomParentMap = $maps['symptomParentMap'];
|
||||
$freeTextMaxLen = $maps['freeTextMaxLen'];
|
||||
|
||||
require_once __DIR__ . '/../lib/app_submit_validate.php';
|
||||
$validationErrors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$qnID,
|
||||
$answers,
|
||||
$shortIdMap,
|
||||
$shortIdToType,
|
||||
$symptomParentMap,
|
||||
$optionMap,
|
||||
$submitManifest
|
||||
);
|
||||
if ($validationErrors !== []) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error(
|
||||
'VALIDATION_FAILED',
|
||||
'Some answers are invalid or missing',
|
||||
400,
|
||||
['errors' => $validationErrors]
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$sumPoints = 0;
|
||||
$answerInsert = $pdo->prepare("
|
||||
$glassByParent = [];
|
||||
$submittedQuestionIds = [];
|
||||
$answerInsert = $pdo->prepare('
|
||||
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
|
||||
ON CONFLICT(clientCode, questionID) DO UPDATE SET
|
||||
answerOptionID = excluded.answerOptionID,
|
||||
freeTextValue = excluded.freeTextValue,
|
||||
numericValue = excluded.numericValue,
|
||||
answeredAt = excluded.answeredAt
|
||||
");
|
||||
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) '
|
||||
. qdb_upsert_update(
|
||||
['clientCode', 'questionID'],
|
||||
[
|
||||
'answerOptionID' => 'excluded.answerOptionID',
|
||||
'freeTextValue' => 'excluded.freeTextValue',
|
||||
'numericValue' => 'excluded.numericValue',
|
||||
'answeredAt' => 'excluded.answeredAt',
|
||||
]
|
||||
)
|
||||
);
|
||||
$answerDelete = $pdo->prepare(
|
||||
'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
|
||||
require_once __DIR__ . '/../lib/app_answers.php';
|
||||
$answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap);
|
||||
|
||||
foreach ($answers as $a) {
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') continue;
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
|
||||
|
||||
// Glass-scale symptoms use string keys (not question localIds).
|
||||
if (isset($symptomParentMap[$shortId])) {
|
||||
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
|
||||
if ($label !== '') {
|
||||
$parentId = $symptomParentMap[$shortId];
|
||||
$glassByParent[$parentId][$shortId] = $label;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve short ID to full questionID
|
||||
$fullQID = $shortIdMap[$shortId] ?? null;
|
||||
if ($fullQID === null) continue; // skip unknown questions
|
||||
if ($fullQID === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$answerOptionID = null;
|
||||
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
|
||||
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
|
||||
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
|
||||
|
||||
// Resolve option key to answerOptionID and accumulate points
|
||||
if ($freeTextValue !== null && $freeTextValue !== '') {
|
||||
$maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
|
||||
$freeTextValue = sanitize_free_text($freeTextValue, $maxLen);
|
||||
if ($freeTextValue === '') {
|
||||
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
|
||||
$submittedQuestionIds[$fullQID] = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve option key to answerOptionID (points computed by scoring engine after save)
|
||||
$optionKey = $a['answerOptionKey'] ?? null;
|
||||
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
||||
$opt = $optionMap[$fullQID][(string)$optionKey];
|
||||
$answerOptionID = $opt['answerOptionID'];
|
||||
$sumPoints += $opt['points'];
|
||||
}
|
||||
|
||||
$hasValue = $answerOptionID !== null
|
||||
|| ($freeTextValue !== null && $freeTextValue !== '')
|
||||
|| $numericValue !== null;
|
||||
if (!$hasValue) {
|
||||
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
|
||||
$submittedQuestionIds[$fullQID] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$answerInsert->execute([
|
||||
@ -120,19 +284,60 @@ if ($method === 'POST') {
|
||||
':nv' => $numericValue,
|
||||
':at' => $answeredAt,
|
||||
]);
|
||||
$submittedQuestionIds[$fullQID] = true;
|
||||
}
|
||||
|
||||
// Upsert completed_questionnaire record
|
||||
$pdo->prepare("
|
||||
foreach ($glassByParent as $parentQID => $symptomLabels) {
|
||||
$json = qdb_build_glass_symptom_json($symptomLabels);
|
||||
if ($json === null) {
|
||||
$answerDelete->execute([':cc' => $clientCode, ':qid' => $parentQID]);
|
||||
} else {
|
||||
$answerInsert->execute([
|
||||
':cc' => $clientCode,
|
||||
':qid' => $parentQID,
|
||||
':aoid' => null,
|
||||
':ftv' => $json,
|
||||
':nv' => null,
|
||||
':at' => $completedAt ?? $startedAt,
|
||||
]);
|
||||
}
|
||||
$submittedQuestionIds[$parentQID] = true;
|
||||
}
|
||||
|
||||
if (!$isLegacySubmit) {
|
||||
$activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID);
|
||||
$staleQuestionIds = array_diff(
|
||||
array_values($activeMaps['shortIdMap']),
|
||||
array_keys($submittedQuestionIds)
|
||||
);
|
||||
if ($staleQuestionIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?'));
|
||||
$staleDelete = $pdo->prepare(
|
||||
"DELETE FROM client_answer WHERE clientCode = ? AND questionID IN ($placeholders)"
|
||||
);
|
||||
$staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds)));
|
||||
}
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
$sumPoints = $isLegacySubmit
|
||||
? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest)
|
||||
: qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
|
||||
|
||||
$pdo->prepare('
|
||||
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
||||
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp)
|
||||
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET
|
||||
assignedByCoach = excluded.assignedByCoach,
|
||||
status = 'completed',
|
||||
startedAt = COALESCE(excluded.startedAt, startedAt),
|
||||
completedAt = excluded.completedAt,
|
||||
sumPoints = excluded.sumPoints
|
||||
")->execute([
|
||||
VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) '
|
||||
. qdb_upsert_update(
|
||||
['clientCode', 'questionnaireID'],
|
||||
[
|
||||
'assignedByCoach' => 'excluded.assignedByCoach',
|
||||
'status' => "'completed'",
|
||||
'startedAt' => 'COALESCE(excluded.startedAt, startedAt)',
|
||||
'completedAt' => 'excluded.completedAt',
|
||||
'sumPoints' => 'excluded.sumPoints',
|
||||
]
|
||||
)
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':qn' => $qnID,
|
||||
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
||||
@ -141,15 +346,34 @@ if ($method === 'POST') {
|
||||
':sp' => $sumPoints,
|
||||
]);
|
||||
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
qdb_record_submission_after_submit(
|
||||
$pdo,
|
||||
$clientCode,
|
||||
$qnID,
|
||||
$tokenRec,
|
||||
$startedAt,
|
||||
$completedAt,
|
||||
$sumPoints,
|
||||
$assignedByCoach,
|
||||
$submitRev,
|
||||
$submitManifest,
|
||||
array_keys($submittedQuestionIds)
|
||||
);
|
||||
|
||||
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
|
||||
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
|
||||
json_success([
|
||||
'submitted' => true,
|
||||
'sumPoints' => $sumPoints,
|
||||
'structureRevision' => $submitRev,
|
||||
'legacySubmit' => $isLegacySubmit,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Internal server error', 500);
|
||||
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,11 +381,93 @@ if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$qnID = $_GET['id'] ?? '';
|
||||
$translations = $_GET['translations'] ?? '';
|
||||
$fetchClients = $_GET['clients'] ?? '';
|
||||
$fetchAnswers = $_GET['answers'] ?? '';
|
||||
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
|
||||
$fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
|
||||
$fetchScoringReview = $_GET['scoringReview'] ?? '';
|
||||
$clientCode = trim($_GET['clientCode'] ?? '');
|
||||
|
||||
if ($fetchScoringProfiles) {
|
||||
require_role(['coach'], $tokenRec);
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
$profiles = array_values(array_filter(
|
||||
qdb_list_scoring_profiles($pdo),
|
||||
static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1
|
||||
));
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']);
|
||||
}
|
||||
|
||||
if ($fetchScoringReview) {
|
||||
require_role(['coach'], $tokenRec);
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
|
||||
$coachID = $tokenRec['entityID'] ?? '';
|
||||
if ($clientCode !== '') {
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$clStmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||
if (!$clStmt->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
$clientCodes = [$clientCode];
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode'
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
$clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
|
||||
}
|
||||
|
||||
if ($fetchAnswersBulk) {
|
||||
require_role(['coach'], $tokenRec);
|
||||
require_once __DIR__ . '/../lib/app_answers.php';
|
||||
$clients = qdb_export_app_bulk_answers_for_coach($pdo, $tokenRec);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
|
||||
}
|
||||
|
||||
if ($fetchAnswers) {
|
||||
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
|
||||
if ($clientCode === '') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('MISSING_PARAM', 'clientCode query param required', 400);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$clStmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||
if (!$clStmt->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/app_answers.php';
|
||||
$questionnaires = qdb_export_app_client_answers_bundle($pdo, $clientCode);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success_sensitive([
|
||||
'clientCode' => $clientCode,
|
||||
'questionnaires' => $questionnaires,
|
||||
], $tokenRec['_token']);
|
||||
}
|
||||
|
||||
if ($fetchClients) {
|
||||
require_role(['coach'], $tokenRec);
|
||||
@ -171,13 +477,14 @@ if ($fetchClients) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT clientCode
|
||||
FROM client
|
||||
WHERE coachID = :cid
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode
|
||||
");
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$completions = [];
|
||||
$notesByClient = [];
|
||||
if (!empty($clientCodes)) {
|
||||
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
||||
$stmt2 = $pdo->prepare("
|
||||
@ -193,50 +500,32 @@ if ($fetchClients) {
|
||||
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||
$stmtNotes = $pdo->prepare(
|
||||
"SELECT clientCode, note FROM client_followup_note WHERE clientCode IN ($placeholders)"
|
||||
);
|
||||
$stmtNotes->execute($clientCodes);
|
||||
foreach ($stmtNotes->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$notesByClient[$row['clientCode']] = (string)($row['note'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$clients = array_map(function ($code) use ($completions) {
|
||||
$clients = array_map(function ($code) use ($completions, $notesByClient) {
|
||||
return [
|
||||
'clientCode' => $code,
|
||||
'clientCode' => $code,
|
||||
'completedQuestionnaires' => $completions[$code] ?? [],
|
||||
'note' => $notesByClient[$code] ?? '',
|
||||
];
|
||||
}, $clientCodes);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['coachID' => $coachID, 'clients' => $clients]);
|
||||
}
|
||||
|
||||
if ($translations) {
|
||||
$result = [];
|
||||
|
||||
$rows = $pdo->query("
|
||||
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
|
||||
FROM question_translation qt
|
||||
JOIN question q ON q.questionID = qt.questionID
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
$rows = $pdo->query("
|
||||
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
|
||||
FROM answer_option_translation aot
|
||||
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(["translations" => $result]);
|
||||
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
|
||||
}
|
||||
|
||||
if ($qnID) {
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
@ -245,10 +534,12 @@ if ($qnID) {
|
||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, configJson
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT questionID, defaultText, type, orderIndex, configJson
|
||||
FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
|
||||
ORDER BY orderIndex'
|
||||
);
|
||||
$stmt->execute([':id' => $qnID]);
|
||||
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@ -259,14 +550,21 @@ if ($qnID) {
|
||||
$shortId = end($parts);
|
||||
|
||||
$layout = $dbQ['type'];
|
||||
$config = json_decode($dbQ['configJson'], true) ?: [];
|
||||
$config = qdb_parse_config_json($dbQ['configJson']);
|
||||
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
||||
|
||||
$q = [
|
||||
'id' => $shortId,
|
||||
'layout' => $layout,
|
||||
'question' => $dbQ['defaultText'],
|
||||
'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'];
|
||||
@ -274,21 +572,42 @@ if ($qnID) {
|
||||
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 ($layout === 'glass_scale_question' && !empty($config['symptoms'])) {
|
||||
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $config);
|
||||
}
|
||||
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['dateRange'])) $q['dateRange'] = $config['dateRange'];
|
||||
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' && isset($config['valueOptions'])) {
|
||||
if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) {
|
||||
$q['options'] = $config['valueOptions'];
|
||||
}
|
||||
|
||||
$ao = $pdo->prepare("
|
||||
SELECT defaultText, points, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) {
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
$ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']);
|
||||
if ($ruleMap !== []) {
|
||||
$q['scoreRuleMap'] = $ruleMap;
|
||||
}
|
||||
}
|
||||
|
||||
$ao = $pdo->prepare(
|
||||
'SELECT defaultText, points, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
|
||||
ORDER BY orderIndex'
|
||||
);
|
||||
$ao->execute([':qid' => $dbQ['questionID']]);
|
||||
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@ -303,20 +622,32 @@ if ($qnID) {
|
||||
$optionsArr[] = $o;
|
||||
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
|
||||
}
|
||||
$q['options'] = $optionsArr;
|
||||
$q['pointsMap'] = $pointsMap;
|
||||
// DB answer_option rows are for choice layouts only — do not overwrite string/value spinners.
|
||||
if (in_array($layout, ['radio_question', 'multi_check_box_question', 'glass_scale_question'], true)) {
|
||||
$q['options'] = $optionsArr;
|
||||
$q['pointsMap'] = $pointsMap;
|
||||
}
|
||||
}
|
||||
|
||||
$questions[] = $q;
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(["meta" => ["id" => $qnID], "questions" => $questions]);
|
||||
json_success([
|
||||
'meta' => ['id' => $qnID],
|
||||
'structureRevision' => $structureRevision,
|
||||
'questions' => $questions,
|
||||
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
||||
'availableLanguages' => qdb_available_language_codes($pdo, $qnID),
|
||||
]);
|
||||
}
|
||||
|
||||
// Default: ordered questionnaire list
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$rows = $pdo->query("
|
||||
SELECT questionnaireID, name, showPoints, conditionJson
|
||||
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
|
||||
COALESCE(structureRevision, 1) AS structureRevision
|
||||
FROM questionnaire
|
||||
WHERE state = 'active'
|
||||
ORDER BY orderIndex
|
||||
@ -324,12 +655,18 @@ $rows = $pdo->query("
|
||||
|
||||
$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(),
|
||||
$item = [
|
||||
'id' => $r['questionnaireID'],
|
||||
'name' => $r['name'],
|
||||
'showPoints' => (bool)(int)$r['showPoints'],
|
||||
'structureRevision' => max(1, (int)$r['structureRevision']),
|
||||
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
|
||||
];
|
||||
$cat = trim($r['categoryKey'] ?? '');
|
||||
if ($cat !== '') {
|
||||
$item['categoryKey'] = $cat;
|
||||
}
|
||||
$list[] = $item;
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
@ -10,7 +9,7 @@ switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
|
||||
if ($callerRole === 'admin') {
|
||||
$coaches = $pdo->query(
|
||||
@ -45,9 +44,7 @@ case 'GET':
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['coaches' => $coaches, 'clients' => $clients]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'assignments', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -62,7 +59,7 @@ case 'POST':
|
||||
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
if ($callerRole === 'supervisor') {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
|
||||
@ -73,7 +70,7 @@ case 'POST':
|
||||
}
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Coach not found or not authorized', 400);
|
||||
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
|
||||
@ -93,10 +90,7 @@ case 'POST':
|
||||
|
||||
json_success(['assigned' => $count]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'assignments', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/settings.php';
|
||||
require_once __DIR__ . '/../lib/login_rate_limit.php';
|
||||
require_once __DIR__ . '/../lib/keycloak_auth.php';
|
||||
|
||||
switch ($route) {
|
||||
|
||||
case 'auth/login':
|
||||
@ -15,23 +19,53 @@ case 'auth/login':
|
||||
json_error('MISSING_FIELDS', 'Username and password are required', 400);
|
||||
}
|
||||
|
||||
$securitySettings = qdb_settings_get();
|
||||
[$allowed, $retryAfter] = qdb_login_rate_limit_check($username, $securitySettings);
|
||||
if (!$allowed) {
|
||||
qdb_login_rate_limit_deny($retryAfter);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT userID, passwordHash, role, entityID, mustChangePassword
|
||||
FROM users WHERE username = :u"
|
||||
);
|
||||
$stmt->execute([':u' => $username]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
if (!$user || !password_verify($password, $user['passwordHash'])) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$retryAfter = qdb_login_rate_limit_record_failure($username, $securitySettings);
|
||||
if ($retryAfter > 0) {
|
||||
qdb_login_rate_limit_deny($retryAfter);
|
||||
}
|
||||
json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401);
|
||||
}
|
||||
|
||||
qdb_login_rate_limit_clear($username);
|
||||
|
||||
$assignedClients = [];
|
||||
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
|
||||
$cStmt = $pdo->prepare(
|
||||
"SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode"
|
||||
);
|
||||
$cStmt->execute([':cid' => $user['entityID']]);
|
||||
$assignedClients = array_map(
|
||||
fn($code) => ['clientCode' => $code],
|
||||
$cStmt->fetchAll(PDO::FETCH_COLUMN)
|
||||
);
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
|
||||
|
||||
if ((int)$user['mustChangePassword'] === 1) {
|
||||
$tempToken = bin2hex(random_bytes(32));
|
||||
token_add($tempToken, 10 * 60, [
|
||||
token_add($tempToken, qdb_session_ttl_seconds($securitySettings, true), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
@ -46,20 +80,25 @@ case 'auth/login':
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
token_add($token, 30 * 24 * 60 * 60, [
|
||||
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
]);
|
||||
json_success([
|
||||
$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());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -94,7 +133,7 @@ case 'auth/change-password':
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT userID, passwordHash, role, entityID FROM users WHERE username = :u"
|
||||
@ -112,6 +151,8 @@ case 'auth/change-password':
|
||||
json_error('FORBIDDEN', 'Token does not match user', 403);
|
||||
}
|
||||
|
||||
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
|
||||
|
||||
if (!password_verify($oldPassword, $user['passwordHash'])) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_CREDENTIALS', 'Old password incorrect', 401);
|
||||
@ -122,12 +163,15 @@ case 'auth/change-password':
|
||||
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
|
||||
)->execute([':h' => $newHash, ':uid' => $user['userID']]);
|
||||
|
||||
token_revoke_all_for_user_on_pdo($pdo, $user['userID']);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
// Revoke the old (possibly temp) token and issue a fresh one
|
||||
token_revoke($bearerToken);
|
||||
$securitySettings = qdb_settings_get();
|
||||
|
||||
// Issue a fresh session for this browser only
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
token_add($newToken, 30 * 24 * 60 * 60, [
|
||||
token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
@ -139,9 +183,101 @@ case 'auth/change-password':
|
||||
'role' => $user['role'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth/keycloak-config':
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
$kcConfig = qdb_keycloak_config();
|
||||
json_success([
|
||||
'enabled' => qdb_keycloak_is_configured($kcConfig),
|
||||
'displayName' => $kcConfig['display_name'],
|
||||
'loginUrl' => qdb_keycloak_is_configured($kcConfig) ? 'auth/keycloak-login' : '',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'auth/keycloak-login':
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
$kcConfig = qdb_keycloak_config();
|
||||
if (!qdb_keycloak_is_configured($kcConfig)) {
|
||||
json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503);
|
||||
}
|
||||
try {
|
||||
$discovery = qdb_keycloak_discovery($kcConfig);
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$params = [
|
||||
'client_id' => $kcConfig['client_id'],
|
||||
'redirect_uri' => $kcConfig['redirect_uri'],
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid profile email',
|
||||
'state' => qdb_keycloak_create_state($nonce),
|
||||
'nonce' => $nonce,
|
||||
];
|
||||
qdb_keycloak_redirect($discovery['authorization_endpoint'] . '?' . http_build_query($params));
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'keycloak-login', null, null, null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth/keycloak-callback':
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
if (isset($_GET['error'])) {
|
||||
json_error('KEYCLOAK_DENIED', 'University login was cancelled or denied', 401);
|
||||
}
|
||||
$code = trim((string)($_GET['code'] ?? ''));
|
||||
$state = trim((string)($_GET['state'] ?? ''));
|
||||
if ($code === '' || $state === '') {
|
||||
json_error('MISSING_FIELDS', 'Keycloak callback requires code and state', 400);
|
||||
}
|
||||
$kcConfig = qdb_keycloak_config();
|
||||
if (!qdb_keycloak_is_configured($kcConfig)) {
|
||||
json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503);
|
||||
}
|
||||
try {
|
||||
qdb_keycloak_verify_state($state);
|
||||
$discovery = qdb_keycloak_discovery($kcConfig);
|
||||
$tokenResponse = qdb_keycloak_exchange_code($kcConfig, $discovery, $code);
|
||||
$accessToken = (string)($tokenResponse['access_token'] ?? '');
|
||||
if ($accessToken === '') {
|
||||
json_error('KEYCLOAK_TOKEN_FAILED', 'University login did not return an access token', 401);
|
||||
}
|
||||
$claims = qdb_keycloak_userinfo($discovery, $accessToken);
|
||||
$username = qdb_keycloak_claim_username($kcConfig, $claims);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT userID, role, entityID, mustChangePassword
|
||||
FROM users WHERE username = :u"
|
||||
);
|
||||
$stmt->execute([':u' => $username]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
if (!$user) {
|
||||
json_error('KEYCLOAK_USER_NOT_ALLOWED', 'University account is not registered for this application', 403);
|
||||
}
|
||||
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
|
||||
if ((int)($user['mustChangePassword'] ?? 0) === 1) {
|
||||
json_error('PASSWORD_CHANGE_REQUIRED', 'Please sign in locally once to change your temporary password before using University login', 403);
|
||||
}
|
||||
|
||||
$securitySettings = qdb_settings_get();
|
||||
$token = bin2hex(random_bytes(32));
|
||||
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
]);
|
||||
qdb_keycloak_finish_html($token, $username, (string)$user['role']);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'keycloak-callback', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -1,23 +1,76 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin'], $tokenRec);
|
||||
|
||||
$dbPath = QDB_PATH;
|
||||
$backupDir = __DIR__ . '/../uploads/backups';
|
||||
|
||||
if (!file_exists($dbPath)) {
|
||||
json_error('NOT_FOUND', 'No database to back up', 404);
|
||||
if ($method !== 'POST') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
$backupDir = QDB_UPLOADS_DIR . '/backups';
|
||||
if (!is_dir($backupDir)) {
|
||||
if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
|
||||
json_error('SERVER_ERROR', 'Could not create backups directory', 500);
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d_H-i-s');
|
||||
$filename = "questionnaire_database.$timestamp";
|
||||
$timestamp = date('Y-m-d_H-i-s');
|
||||
|
||||
if (qdb_is_mysql()) {
|
||||
[$host, $port] = [qdb_env_get('QDB_MYSQL_HOST') ?: '127.0.0.1', qdb_env_get('QDB_MYSQL_PORT') ?: '3306'];
|
||||
[$user, $pass] = qdb_mysql_credentials();
|
||||
$db = qdb_env_get('QDB_MYSQL_DATABASE') ?: 'nat-as-db';
|
||||
$filename = "questionnaire_mysql_{$timestamp}.sql";
|
||||
$backupFile = "$backupDir/$filename";
|
||||
if (!function_exists('proc_open')) {
|
||||
json_error('SERVER_ERROR', 'MySQL backup is unavailable: proc_open is disabled', 500);
|
||||
}
|
||||
|
||||
$command = [
|
||||
'mysqldump',
|
||||
'--single-transaction',
|
||||
'--routines',
|
||||
'--triggers',
|
||||
"--host={$host}",
|
||||
"--port={$port}",
|
||||
"--user={$user}",
|
||||
$db,
|
||||
];
|
||||
$environment = getenv();
|
||||
if (!is_array($environment)) {
|
||||
$environment = [];
|
||||
}
|
||||
if ($pass !== '') {
|
||||
$environment['MYSQL_PWD'] = $pass;
|
||||
}
|
||||
$descriptors = [
|
||||
0 => ['file', '/dev/null', 'r'],
|
||||
1 => ['file', $backupFile, 'wb'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$process = @proc_open($command, $descriptors, $pipes, null, $environment);
|
||||
if (!is_resource($process)) {
|
||||
@unlink($backupFile);
|
||||
json_error('SERVER_ERROR', 'MySQL backup failed to start mysqldump', 500);
|
||||
}
|
||||
$errorOutput = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$code = proc_close($process);
|
||||
if ($code !== 0 || !is_file($backupFile) || filesize($backupFile) === 0) {
|
||||
@unlink($backupFile);
|
||||
$detail = trim((string)$errorOutput);
|
||||
json_error('SERVER_ERROR', 'MySQL backup failed' . ($detail !== '' ? ": {$detail}" : ''), 500);
|
||||
}
|
||||
@chmod($backupFile, 0600);
|
||||
json_success(['filename' => $filename]);
|
||||
}
|
||||
|
||||
$dbPath = QDB_PATH;
|
||||
if (!file_exists($dbPath)) {
|
||||
json_error('NOT_FOUND', 'No database to back up', 404);
|
||||
}
|
||||
|
||||
$filename = "questionnaire_database.$timestamp";
|
||||
$backupFile = "$backupDir/$filename";
|
||||
|
||||
if (!copy($dbPath, $backupFile)) {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
@ -10,42 +9,80 @@ switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||
$clientCode = trim((string)($_GET['clientCode'] ?? ''));
|
||||
if ($clientCode !== '' && !empty($_GET['detail'])) {
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
$detail = qdb_client_detail($pdo, $tokenRec, $clientCode);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success($detail);
|
||||
}
|
||||
|
||||
$archiveFilter = trim((string)($_GET['archiveFilter'] ?? 'active'));
|
||||
if (!in_array($archiveFilter, ['active', 'archived', 'all'], true)) {
|
||||
$archiveFilter = 'active';
|
||||
}
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', $archiveFilter);
|
||||
$noteSelect = qdb_table_exists($pdo, 'client_followup_note')
|
||||
? 'n.note AS note'
|
||||
: "'' AS note";
|
||||
$noteJoin = qdb_table_exists($pdo, 'client_followup_note')
|
||||
? 'LEFT JOIN client_followup_note n ON n.clientCode = cl.clientCode'
|
||||
: '';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
||||
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
|
||||
co.username AS coachUsername,
|
||||
$noteSelect,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM questionnaire_submission qs WHERE qs.clientCode = cl.clientCode
|
||||
) THEN 1 ELSE 0 END AS hasResponseData
|
||||
FROM client cl
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
$noteJoin
|
||||
WHERE $clause
|
||||
ORDER BY cl.clientCode"
|
||||
ORDER BY cl.archived ASC, hasResponseData DESC, cl.clientCode ASC"
|
||||
);
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||||
$stmt->execute();
|
||||
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
|
||||
foreach ($clients as &$client) {
|
||||
$client['note'] = (string)($client['note'] ?? '');
|
||||
$client['scoringProfiles'] = qdb_client_scoring_dots_for_client(
|
||||
$client['clientCode'],
|
||||
$scoringSummary
|
||||
);
|
||||
}
|
||||
unset($client);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['clients' => $clients]);
|
||||
json_success([
|
||||
'clients' => $clients,
|
||||
'scoringProfiles' => $scoringSummary['profiles'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = read_json_body();
|
||||
$clientCode = trim($body['clientCode'] ?? '');
|
||||
$coachID = trim($body['coachID'] ?? '');
|
||||
$body = read_json_body();
|
||||
|
||||
if ($clientCode === '' || $coachID === '') {
|
||||
json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400);
|
||||
$coachID = trim($body['coachID'] ?? '');
|
||||
$bulk = !empty($body['bulk']);
|
||||
|
||||
if ($coachID === '') {
|
||||
json_error('MISSING_FIELDS', 'coachID is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
// Validate coach exists and caller is allowed to assign to them
|
||||
if ($callerRole === 'supervisor') {
|
||||
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
|
||||
$chkCoach->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
|
||||
@ -55,7 +92,58 @@ case 'POST':
|
||||
}
|
||||
if (!$chkCoach->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Coach not found or not authorized', 400);
|
||||
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
|
||||
}
|
||||
|
||||
if ($bulk) {
|
||||
$fromCode = trim($body['fromCode'] ?? '');
|
||||
$toCode = trim($body['toCode'] ?? '');
|
||||
if ($fromCode === '' || $toCode === '') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('MISSING_FIELDS', 'fromCode and toCode are required for bulk creation', 400);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/client_code_range.php';
|
||||
try {
|
||||
$codes = qdb_expand_client_code_range($fromCode, $toCode);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_RANGE', $e->getMessage(), 400);
|
||||
}
|
||||
|
||||
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
|
||||
$insert = $pdo->prepare(
|
||||
"INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
|
||||
);
|
||||
|
||||
$created = [];
|
||||
$skipped = [];
|
||||
foreach ($codes as $code) {
|
||||
$chk->execute([':cc' => $code]);
|
||||
if ($chk->fetch()) {
|
||||
$skipped[] = ['clientCode' => $code, 'reason' => 'duplicate'];
|
||||
continue;
|
||||
}
|
||||
$insert->execute([':cc' => $code, ':cid' => $coachID]);
|
||||
$created[] = $code;
|
||||
}
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'coachID' => $coachID,
|
||||
'fromCode' => $fromCode,
|
||||
'toCode' => $toCode,
|
||||
'created' => $created,
|
||||
'skipped' => $skipped,
|
||||
'createdCount' => count($created),
|
||||
'skippedCount' => count($skipped),
|
||||
]);
|
||||
}
|
||||
|
||||
$clientCode = trim($body['clientCode'] ?? '');
|
||||
if ($clientCode === '') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('MISSING_FIELDS', 'clientCode is required', 400);
|
||||
}
|
||||
|
||||
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
|
||||
@ -65,15 +153,107 @@ case 'POST':
|
||||
json_error('DUPLICATE', 'Client code already exists', 409);
|
||||
}
|
||||
|
||||
$pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)")
|
||||
->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||
$pdo->prepare(
|
||||
"INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
|
||||
)->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
$body = read_json_body();
|
||||
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||
$profileID = trim((string)($body['profileID'] ?? ''));
|
||||
$coachBand = trim((string)($body['coachBand'] ?? ''));
|
||||
|
||||
if ($clientCode === '' || $profileID === '' || $coachBand === '') {
|
||||
json_error('MISSING_FIELDS', 'clientCode, profileID, and coachBand are required', 400);
|
||||
}
|
||||
|
||||
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
|
||||
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||
$chk = $pdo->prepare(
|
||||
"SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
|
||||
);
|
||||
$chk->execute(array_merge([':cc' => $clientCode], $params));
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
|
||||
}
|
||||
|
||||
$profile = qdb_save_coach_scoring_review(
|
||||
$pdo,
|
||||
$clientCode,
|
||||
$profileID,
|
||||
$coachBand,
|
||||
(string)($tokenRec['userID'] ?? ''),
|
||||
$weightedTotal,
|
||||
$calculatedBand !== '' ? $calculatedBand : null,
|
||||
);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['profile' => $profile]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (RuntimeException $e) {
|
||||
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||
json_error('NOT_FOUND', $e->getMessage(), 404);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PATCH':
|
||||
$body = read_json_body();
|
||||
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||
if ($clientCode === '' || !array_key_exists('archived', $body)) {
|
||||
json_error('MISSING_FIELDS', 'clientCode and archived are required', 400);
|
||||
}
|
||||
$archived = !empty($body['archived']) ? 1 : 0;
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', 'all');
|
||||
$chk = $pdo->prepare(
|
||||
"SELECT clientCode, archived FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
|
||||
);
|
||||
$chk->execute(array_merge([':cc' => $clientCode], $params));
|
||||
$row = $chk->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
$archivedAt = $archived ? time() : 0;
|
||||
$pdo->prepare(
|
||||
'UPDATE client SET archived = :a, archivedAt = :at WHERE clientCode = :cc'
|
||||
)->execute([':a' => $archived, ':at' => $archivedAt, ':cc' => $clientCode]);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'clientCode' => $clientCode,
|
||||
'archived' => $archived,
|
||||
'archivedAt' => $archivedAt,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -86,7 +266,7 @@ case 'DELETE':
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
// Verify the client exists and the caller can see it (RBAC)
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||
@ -99,22 +279,18 @@ case 'DELETE':
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc")
|
||||
->execute([':cc' => $clientCode]);
|
||||
$pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc")
|
||||
->execute([':cc' => $clientCode]);
|
||||
$pdo->prepare("DELETE FROM client WHERE clientCode = :cc")
|
||||
qdb_delete_client_response_data($pdo, [$clientCode]);
|
||||
$pdo->prepare('DELETE FROM client WHERE clientCode = :cc')
|
||||
->execute([':cc' => $clientCode]);
|
||||
$pdo->commit();
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
29
handlers/coaches.php
Normal file
29
handlers/coaches.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
require_once __DIR__ . '/../lib/analytics.php';
|
||||
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$coachID = trim((string)($_GET['coachID'] ?? ''));
|
||||
if ($coachID !== '' && !empty($_GET['recent'])) {
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
|
||||
$offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0;
|
||||
$recent = qdb_coach_recent_submissions($pdo, $tokenRec, $coachID, $limit, $offset);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success($recent);
|
||||
}
|
||||
|
||||
$coaches = qdb_coach_activity_list($pdo, $tokenRec);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['coaches' => $coaches]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load coaches', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
53
handlers/dev.php
Normal file
53
handlers/dev.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin-only dev fixture import (local / test environments).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/dev_fixture.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin'], $tokenRec);
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
$body = read_json_body();
|
||||
$action = trim((string)($body['action'] ?? 'import'));
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
if ($action === 'remove') {
|
||||
$result = qdb_remove_dev_test_data($pdo, [
|
||||
'usernamePrefix' => $body['usernamePrefix'] ?? 'dev_',
|
||||
'clientCodePrefix' => $body['clientCodePrefix'] ?? 'DEV-CL-',
|
||||
]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success($result);
|
||||
}
|
||||
|
||||
if ($action === 'wipeExceptAdmins') {
|
||||
$result = qdb_wipe_all_data_except_admins($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success($result);
|
||||
}
|
||||
|
||||
$fixture = $body['fixture'] ?? null;
|
||||
if (!is_array($fixture)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('MISSING_FIELDS', 'fixture object is required for import', 400);
|
||||
}
|
||||
|
||||
$result = qdb_import_dev_fixture($pdo, $fixture);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success($result);
|
||||
} catch (Throwable $e) {
|
||||
$labels = [
|
||||
'remove' => 'Remove dev data',
|
||||
'wipeExceptAdmins' => 'Wipe database',
|
||||
'import' => 'Import dev fixture',
|
||||
];
|
||||
$label = $labels[$action ?? 'import'] ?? 'Dev fixture';
|
||||
qdb_handler_fail($e, $label, $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$token = $tokenRec['_token'];
|
||||
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
$dbPath = QDB_PATH;
|
||||
if (!file_exists($dbPath)) {
|
||||
json_error('NOT_FOUND', 'Database not found', 404);
|
||||
}
|
||||
|
||||
$encMaster = file_get_contents($dbPath);
|
||||
if ($encMaster === false) {
|
||||
json_error('SERVER_ERROR', 'Could not read database', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
$masterKey = get_master_key_bytes();
|
||||
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
|
||||
} catch (Throwable $e) {
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Decryption failed', 500);
|
||||
}
|
||||
|
||||
$role = $tokenRec['role'] ?? '';
|
||||
if ($role !== 'admin') {
|
||||
$tmpFilter = tempnam(sys_get_temp_dir(), 'qdb_filt_');
|
||||
file_put_contents($tmpFilter, $plain);
|
||||
try {
|
||||
$fpdo = new PDO("sqlite:$tmpFilter");
|
||||
$fpdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$fpdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec);
|
||||
$delStmt = $fpdo->prepare("DELETE FROM client WHERE NOT ($clause)");
|
||||
foreach ($params as $k => $v) $delStmt->bindValue($k, $v);
|
||||
$delStmt->execute();
|
||||
|
||||
$fpdo->exec("DELETE FROM client_answer WHERE clientCode NOT IN (SELECT clientCode FROM client)");
|
||||
$fpdo->exec("DELETE FROM completed_questionnaire WHERE clientCode NOT IN (SELECT clientCode FROM client)");
|
||||
|
||||
foreach (['users', 'admin', 'supervisor', 'coach'] as $tbl) {
|
||||
$fpdo->exec("DELETE FROM $tbl");
|
||||
}
|
||||
|
||||
$fpdo = null;
|
||||
$plain = file_get_contents($tmpFilter);
|
||||
} finally {
|
||||
@unlink($tmpFilter);
|
||||
}
|
||||
}
|
||||
|
||||
$type = strtolower(trim($_GET['type'] ?? 'encrypted'));
|
||||
|
||||
if ($type === 'plain') {
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: attachment; filename="questionnaire_database.sqlite"');
|
||||
header('Content-Length: ' . strlen($plain));
|
||||
echo $plain;
|
||||
exit;
|
||||
}
|
||||
|
||||
$sessionKey = hkdf_session_key_from_token($token);
|
||||
$out = aes256_cbc_encrypt_bytes($plain, $sessionKey);
|
||||
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: attachment; filename="questionnaire_database"');
|
||||
header('Content-Length: ' . strlen($out));
|
||||
echo $out;
|
||||
exit;
|
||||
@ -1,19 +1,70 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (!empty($_GET['bundle'])) {
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$bundle = qdb_export_all_questionnaires_bundle($pdo);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
$filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json';
|
||||
qdb_http_download(
|
||||
json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
|
||||
[
|
||||
'Content-Type' => 'application/json; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Export questionnaires bundle', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($_GET['exportAll'])) {
|
||||
require_role(['admin'], $tokenRec);
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$allVersions = !empty($_GET['allVersions']);
|
||||
$zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
$label = $allVersions ? 'all_versions' : 'current';
|
||||
$filename = 'responses_export_' . $label . '_' . date('Y-m-d_His') . '.zip';
|
||||
$zipBody = (string)file_get_contents($zipPath);
|
||||
@unlink($zipPath);
|
||||
|
||||
qdb_http_download($zipBody, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Content-Length' => (string)strlen($zipBody),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Export responses ZIP', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
if (!$qnID) {
|
||||
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$allVersions = !empty($_GET['allVersions']);
|
||||
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$questionnaire) {
|
||||
@ -21,99 +72,40 @@ if (!$questionnaire) {
|
||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||
}
|
||||
|
||||
$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
|
||||
$questions = $ctx['questions'];
|
||||
$resultColumns = $ctx['resultColumns'];
|
||||
$optionTextMap = $ctx['optionTextMap'];
|
||||
|
||||
$optionTextMap = [];
|
||||
foreach ($questionIDs as $qid) {
|
||||
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid");
|
||||
$aoStmt->execute([':qid' => $qid]);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
|
||||
}
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
WHERE $rbacClause
|
||||
ORDER BY cl.clientCode
|
||||
";
|
||||
$params = array_merge([':qnid' => $qnID], $rbacParams);
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
$rows = [];
|
||||
foreach ($clients as $c) {
|
||||
$row = [
|
||||
'clientCode' => $c['clientCode'],
|
||||
'coachID' => $c['coachID'],
|
||||
'status' => $c['status'],
|
||||
'sumPoints' => $c['sumPoints'],
|
||||
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
|
||||
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
||||
];
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$qid = $q['questionID'];
|
||||
if (isset($answerMap[$qid])) {
|
||||
$a = $answerMap[$qid];
|
||||
if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) {
|
||||
$row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']];
|
||||
} elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') {
|
||||
$row[$q['defaultText']] = $a['freeTextValue'];
|
||||
} elseif ($a['numericValue'] !== null) {
|
||||
$row[$q['defaultText']] = $a['numericValue'];
|
||||
} else {
|
||||
$row[$q['defaultText']] = '';
|
||||
}
|
||||
} else {
|
||||
$row[$q['defaultText']] = '';
|
||||
}
|
||||
}
|
||||
$rows[] = $row;
|
||||
if ($allVersions) {
|
||||
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
$safeName = qdb_export_safe_basename($questionnaire['name']);
|
||||
$filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv';
|
||||
|
||||
qdb_http_download(
|
||||
qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($pdo, $resultColumns)),
|
||||
[
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
// CSV output (not JSON -- overrides the router's Content-Type)
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
|
||||
$safeName = qdb_export_safe_basename($questionnaire['name']);
|
||||
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
|
||||
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
if (!empty($rows)) {
|
||||
fputcsv($out, array_keys($rows[0]));
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, array_values($row));
|
||||
}
|
||||
} else {
|
||||
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
foreach ($questions as $q) $header[] = $q['defaultText'];
|
||||
fputcsv($out, $header);
|
||||
qdb_http_download(
|
||||
qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($pdo, $resultColumns)),
|
||||
[
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Export CSV', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
fclose($out);
|
||||
|
||||
@ -9,5 +9,10 @@ if (!$token) {
|
||||
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
|
||||
}
|
||||
|
||||
token_revoke($token);
|
||||
try {
|
||||
token_revoke($token);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Logout');
|
||||
}
|
||||
|
||||
json_success(['loggedOut' => true]);
|
||||
|
||||
@ -1,33 +1,123 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
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'] ?: '{}';
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$sql = "
|
||||
SELECT q.questionnaireID, q.name, q.version, q.state,
|
||||
q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey,
|
||||
COUNT(qu.questionID) AS questionCount,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM completed_questionnaire cq
|
||||
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
||||
WHERE cq.questionnaireID = q.questionnaireID
|
||||
AND $rbacClause
|
||||
) AS completedCount
|
||||
FROM questionnaire q
|
||||
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
|
||||
GROUP BY q.questionnaireID
|
||||
ORDER BY q.orderIndex, q.name
|
||||
";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($rbacParams);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$r) {
|
||||
$r['orderIndex'] = (int)$r['orderIndex'];
|
||||
$r['showPoints'] = (int)$r['showPoints'];
|
||||
$r['questionCount'] = (int)$r['questionCount'];
|
||||
$r['completedCount'] = (int)$r['completedCount'];
|
||||
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
|
||||
}
|
||||
unset($r);
|
||||
$categoryKeys = qdb_list_category_keys_for_dashboard($pdo);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load questionnaires', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
unset($r);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['questionnaires' => $rows]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
|
||||
if (($body['action'] ?? '') === 'updateConditionMessageLabel') {
|
||||
$messageKey = trim((string)($body['messageKey'] ?? ''));
|
||||
$germanLabel = (string)($body['germanLabel'] ?? '');
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
qdb_set_app_string_german_label($pdo, $messageKey, $germanLabel);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'messageKey' => qdb_validate_stable_key($messageKey, 'messageKey'),
|
||||
'germanLabel' => trim($germanLabel),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Update condition message label', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (($body['action'] ?? '') === 'updateCategoryLabel') {
|
||||
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
|
||||
$germanLabel = (string)($body['germanLabel'] ?? '');
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$stringKey = qdb_set_category_german_label($pdo, $categoryKey, $germanLabel);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'categoryKey' => $categoryKey,
|
||||
'stringKey' => $stringKey,
|
||||
'germanLabel' => trim($germanLabel),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Update category label', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (($body['action'] ?? '') === 'deleteCategoryKey') {
|
||||
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
|
||||
if ($categoryKey === '') {
|
||||
json_error('INVALID_FIELD', 'categoryKey is required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$cleared = qdb_delete_category_key($pdo, $categoryKey);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => $categoryKey, 'questionnairesCleared' => $cleared]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Delete category key', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (($body['action'] ?? '') === 'importBundle') {
|
||||
$bundle = $body['bundle'] ?? null;
|
||||
if (!is_array($bundle)) {
|
||||
json_error('MISSING_FIELDS', 'bundle object is required', 400);
|
||||
}
|
||||
$replaceIfExists = !empty($body['replaceIfExists']);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
qdb_set_foreign_keys($pdo, false);
|
||||
$result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists);
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success($result);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Import questionnaires', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (empty($body['name'])) {
|
||||
json_error('NAME_REQUIRED', 'name is required', 400);
|
||||
}
|
||||
@ -39,15 +129,19 @@ case 'POST':
|
||||
$showPts = (int)($body['showPoints'] ?? 0);
|
||||
$condJson = $body['conditionJson'] ?? '{}';
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
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)")
|
||||
$catKey = trim($body['categoryKey'] ?? '');
|
||||
if ($catKey !== '') {
|
||||
$catKey = qdb_normalize_stable_key($catKey);
|
||||
}
|
||||
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
|
||||
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson]);
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['questionnaire' => [
|
||||
'questionnaireID' => $id, 'name' => $name, 'version' => $version,
|
||||
@ -55,9 +149,7 @@ case 'POST':
|
||||
'conditionJson' => $condJson, 'questionCount' => 0,
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Create questionnaire', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -69,7 +161,7 @@ case 'PUT':
|
||||
}
|
||||
$id = $body['questionnaireID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$existing = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
||||
$existing->execute([':id' => $id]);
|
||||
@ -84,21 +176,25 @@ case 'PUT':
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$showPts = (int)($body['showPoints'] ?? $row['showPoints']);
|
||||
$condJson = $body['conditionJson'] ?? $row['conditionJson'];
|
||||
$catKey = array_key_exists('categoryKey', $body)
|
||||
? trim((string)$body['categoryKey'])
|
||||
: trim($row['categoryKey'] ?? '');
|
||||
if ($catKey !== '') {
|
||||
$catKey = qdb_normalize_stable_key($catKey);
|
||||
}
|
||||
|
||||
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
|
||||
orderIndex = :o, showPoints = :sp, conditionJson = :cj
|
||||
orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck
|
||||
WHERE questionnaireID = :id")
|
||||
->execute([':n' => $name, ':v' => $version, ':s' => $state,
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]);
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey, ':id' => $id]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['questionnaire' => [
|
||||
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
|
||||
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Update questionnaire', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -110,9 +206,9 @@ case 'DELETE':
|
||||
}
|
||||
$id = $body['questionnaireID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||
qdb_set_foreign_keys($pdo, false);
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
||||
@ -135,16 +231,11 @@ case 'DELETE':
|
||||
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Delete questionnaire', $pdo, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
require_once __DIR__ . '/../lib/questionnaire_structure.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
switch ($method) {
|
||||
|
||||
@ -9,50 +12,92 @@ case 'GET':
|
||||
if (!$qnID) {
|
||||
json_error('BAD_REQUEST', 'questionnaireID query param required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
|
||||
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
|
||||
");
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$includeRetired = !empty($_GET['includeRetired']);
|
||||
$qWhere = 'questionnaireID = :qid';
|
||||
if (!$includeRetired) {
|
||||
$qWhere .= ' AND ' . qdb_active_questions_clause('question');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson,
|
||||
retiredAt, retiredInRevision
|
||||
FROM question WHERE $qWhere ORDER BY orderIndex"
|
||||
);
|
||||
$stmt->execute([':qid' => $qnID]);
|
||||
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
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
|
||||
");
|
||||
$q['retiredAt'] = (int)($q['retiredAt'] ?? 0);
|
||||
$q['retiredInRevision'] = (int)($q['retiredInRevision'] ?? 0);
|
||||
$q['retired'] = $q['retiredAt'] > 0;
|
||||
$cfg = qdb_parse_config_json($q['configJson']);
|
||||
$q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
||||
$q['questionKey'] = qdb_question_key($cfg, $q['defaultText']);
|
||||
$q['localId'] = qdb_question_local_id($q['questionID'], $qnID);
|
||||
$ao = $pdo->prepare(
|
||||
"SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId, retiredAt
|
||||
FROM answer_option WHERE questionID = :qid AND " . qdb_active_options_clause('answer_option') . '
|
||||
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'];
|
||||
$opt['optionKey'] = qdb_option_key($opt);
|
||||
$opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']);
|
||||
}
|
||||
unset($opt);
|
||||
if (($q['type'] ?? '') === 'glass_scale_question') {
|
||||
$q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
|
||||
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
|
||||
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
|
||||
}
|
||||
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
|
||||
$tr->execute([':qid' => $q['questionID']]);
|
||||
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
unset($q);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['questions' => $questions]);
|
||||
unset($q);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'questions' => $questions,
|
||||
'structureRevision' => $structureRevision,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
if (empty($body['questionnaireID']) || !isset($body['defaultText'])) {
|
||||
json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400);
|
||||
if (empty($body['questionnaireID']) || !isset($body['defaultText']) || empty($body['questionKey'])) {
|
||||
json_error('BAD_REQUEST', 'questionnaireID, questionKey, and defaultText required', 400);
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$qnID = $body['questionnaireID'];
|
||||
$text = trim($body['defaultText']);
|
||||
qdb_require_non_empty_german($text);
|
||||
$questionKey = qdb_validate_stable_key((string)$body['questionKey'], 'Question key');
|
||||
$type = trim($body['type'] ?? '');
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$req = (int)($body['isRequired'] ?? 0);
|
||||
$config = $body['configJson'] ?? '{}';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$cfg = qdb_parse_config_json($body['configJson'] ?? '{}');
|
||||
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfg);
|
||||
if ($type === 'glass_scale_question') {
|
||||
$cfg = qdb_apply_glass_symptoms_config($cfg, $glassSymptoms);
|
||||
}
|
||||
$config = qdb_normalize_question_config(
|
||||
$cfg,
|
||||
$questionKey,
|
||||
$body['noteBefore'] ?? ($cfg['noteBefore'] ?? null),
|
||||
$body['noteAfter'] ?? ($cfg['noteAfter'] ?? null)
|
||||
);
|
||||
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
|
||||
$chk->execute([':id' => $qnID]);
|
||||
@ -65,20 +110,47 @@ case 'POST':
|
||||
$max->execute([':id' => $qnID]);
|
||||
$order = (int)$max->fetchColumn();
|
||||
}
|
||||
$localId = trim($body['localId'] ?? '');
|
||||
if ($localId === '') {
|
||||
$localId = qdb_allocate_question_local_id($pdo, $qnID);
|
||||
}
|
||||
$id = qdb_make_question_id($qnID, $localId);
|
||||
$dup = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id');
|
||||
$dup->execute([':id' => $id]);
|
||||
if ($dup->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('CONFLICT', "Question id \"$localId\" already exists in this questionnaire", 409);
|
||||
}
|
||||
$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]);
|
||||
':o' => $order, ':r' => $req, ':cj' => $configJson]);
|
||||
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
||||
if ($type === 'glass_scale_question') {
|
||||
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
||||
}
|
||||
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
|
||||
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
||||
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
||||
}
|
||||
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_added');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['question' => [
|
||||
json_success([
|
||||
'structureRevision' => $newRev,
|
||||
'question' => [
|
||||
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
||||
'questionKey' => $questionKey, 'localId' => $localId,
|
||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||
'configJson' => $config, 'answerOptions' => [], 'translations' => [],
|
||||
]]);
|
||||
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
|
||||
'glassSymptoms' => $type === 'glass_scale_question'
|
||||
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
||||
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
||||
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -89,7 +161,7 @@ case 'PUT':
|
||||
json_error('BAD_REQUEST', 'questionID is required', 400);
|
||||
}
|
||||
$id = $body['questionID'];
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
|
||||
$existing->execute([':id' => $id]);
|
||||
@ -99,22 +171,71 @@ case 'PUT':
|
||||
json_error('NOT_FOUND', 'Question not found', 404);
|
||||
}
|
||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||
qdb_require_non_empty_german($text);
|
||||
$type = trim($body['type'] ?? $row['type']);
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$req = (int)($body['isRequired'] ?? $row['isRequired']);
|
||||
$config = $body['configJson'] ?? $row['configJson'];
|
||||
$oldCfg = qdb_parse_config_json($row['configJson']);
|
||||
$questionKey = isset($body['questionKey'])
|
||||
? qdb_validate_stable_key((string)$body['questionKey'], 'Question key')
|
||||
: qdb_question_key($oldCfg, $row['defaultText']);
|
||||
if ($questionKey === '') {
|
||||
json_error('MISSING_FIELDS', 'questionKey is required', 400);
|
||||
}
|
||||
$cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg;
|
||||
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfgIn);
|
||||
if ($type === 'glass_scale_question') {
|
||||
$cfgIn = qdb_apply_glass_symptoms_config($cfgIn, $glassSymptoms);
|
||||
}
|
||||
$config = qdb_normalize_question_config(
|
||||
$cfgIn,
|
||||
$questionKey,
|
||||
$body['noteBefore'] ?? ($cfgIn['noteBefore'] ?? null),
|
||||
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
|
||||
);
|
||||
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
||||
$oldKey = qdb_question_key($oldCfg, $row['defaultText']);
|
||||
$oldSymptoms = json_encode($oldCfg['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$newSymptoms = json_encode($config['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$structuralChange = ($type !== (string)$row['type'])
|
||||
|| ($req !== (int)$row['isRequired'])
|
||||
|| ($questionKey !== $oldKey)
|
||||
|| ($newSymptoms !== $oldSymptoms);
|
||||
if ((int)($row['retiredAt'] ?? 0) > 0) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', 'Cannot edit a retired question', 400);
|
||||
}
|
||||
$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]);
|
||||
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
|
||||
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
||||
if ($type === 'glass_scale_question') {
|
||||
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
||||
}
|
||||
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
|
||||
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
||||
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
||||
}
|
||||
$newRev = null;
|
||||
if ($structuralChange) {
|
||||
$newRev = qdb_bump_structure_revision($pdo, (string)$row['questionnaireID'], 'question_updated');
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['question' => [
|
||||
json_success([
|
||||
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
|
||||
'question' => [
|
||||
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
||||
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||
'configJson' => $config,
|
||||
]]);
|
||||
'defaultText' => $text, 'questionKey' => $questionKey,
|
||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||
'configJson' => $configJson,
|
||||
'glassSymptoms' => $type === 'glass_scale_question'
|
||||
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
||||
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
||||
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -125,30 +246,57 @@ case 'DELETE':
|
||||
json_error('BAD_REQUEST', 'questionID is required', 400);
|
||||
}
|
||||
$id = $body['questionID'];
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$existing = $pdo->prepare('SELECT questionnaireID, retiredAt FROM question WHERE questionID = :id');
|
||||
$existing->execute([':id' => $id]);
|
||||
$row = $existing->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Question not found', 404);
|
||||
}
|
||||
$qnID = (string)$row['questionnaireID'];
|
||||
if ((int)($row['retiredAt'] ?? 0) > 0) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID)]);
|
||||
break;
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id");
|
||||
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_removed');
|
||||
|
||||
if (qdb_question_has_client_data($pdo, $id)) {
|
||||
qdb_retire_question($pdo, $id, $newRev);
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'deleted' => false,
|
||||
'retired' => true,
|
||||
'structureRevision' => $newRev,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
qdb_set_foreign_keys($pdo, false);
|
||||
$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_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->prepare('DELETE FROM answer_option WHERE questionID = :id')->execute([':id' => $id]);
|
||||
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :id')->execute([':id' => $id]);
|
||||
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id')->execute([':id' => $id]);
|
||||
$pdo->prepare('DELETE FROM question WHERE questionID = :id')->execute([':id' => $id]);
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
$pdo->commit();
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => true]);
|
||||
json_success([
|
||||
'deleted' => true,
|
||||
'retired' => false,
|
||||
'structureRevision' => $newRev,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -158,18 +306,49 @@ case 'PATCH':
|
||||
if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
|
||||
json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$qnID = (string)$body['questionnaireID'];
|
||||
$orderIds = array_values(array_filter(
|
||||
array_map('strval', $body['order']),
|
||||
static fn($id) => $id !== ''
|
||||
));
|
||||
if ($orderIds === []) {
|
||||
json_error('BAD_REQUEST', 'order[] must list at least one questionID', 400);
|
||||
}
|
||||
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']]);
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
|
||||
$chk->execute([':id' => $qnID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($orderIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT questionID FROM question WHERE questionnaireID = ? AND questionID IN ($ph)"
|
||||
);
|
||||
$stmt->execute(array_merge([$qnID], $orderIds));
|
||||
$found = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
if (count($found) !== count($orderIds)) {
|
||||
$missing = array_values(array_diff($orderIds, $found));
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error(
|
||||
'INVALID_FIELD',
|
||||
'order[] contains questionIDs not in this questionnaire',
|
||||
400,
|
||||
['missingQuestionIDs' => $missing]
|
||||
);
|
||||
}
|
||||
$upd = $pdo->prepare(
|
||||
'UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid'
|
||||
);
|
||||
foreach ($orderIds as $idx => $qid) {
|
||||
$upd->execute([':o' => $idx, ':id' => $qid, ':qid' => $qnID]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['reordered' => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Reorder questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
@ -13,9 +15,11 @@ if (!$qnID) {
|
||||
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$questionnaire) {
|
||||
@ -24,7 +28,7 @@ if (!$questionnaire) {
|
||||
}
|
||||
|
||||
$qStmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, isRequired
|
||||
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
@ -34,6 +38,8 @@ $questionIDs = array_column($questions, 'questionID');
|
||||
foreach ($questions as &$q) {
|
||||
$q['isRequired'] = (int)$q['isRequired'];
|
||||
$q['orderIndex'] = (int)$q['orderIndex'];
|
||||
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$q['questionKey'] = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID);
|
||||
$ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
|
||||
$ao->execute([':qid' => $q['questionID']]);
|
||||
@ -49,9 +55,13 @@ unset($q);
|
||||
|
||||
$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);
|
||||
@ -104,10 +114,20 @@ if (!empty($questionIDs) && !empty($clients)) {
|
||||
unset($c);
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
|
||||
foreach ($clients as &$c) {
|
||||
$c['scoringProfiles'] = qdb_client_scoring_dots_for_client($c['clientCode'], $scoringSummary);
|
||||
}
|
||||
unset($c);
|
||||
|
||||
json_success([
|
||||
"questionnaire" => $questionnaire,
|
||||
"questions" => $questions,
|
||||
"clients" => $clients,
|
||||
]);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
json_success([
|
||||
'questionnaire' => $questionnaire,
|
||||
'questions' => $questions,
|
||||
'clients' => $clients,
|
||||
'scoringProfiles' => $scoringSummary['profiles'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
|
||||
209
handlers/scoring_profiles.php
Normal file
209
handlers/scoring_profiles.php
Normal file
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
/**
|
||||
* Scoring profiles CRUD (weighted multi-questionnaire Green/Yellow/Red bands).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$profileID = trim($_GET['id'] ?? '');
|
||||
if ($profileID !== '') {
|
||||
$profile = qdb_get_scoring_profile($pdo, $profileID);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
if (!$profile) {
|
||||
json_error('NOT_FOUND', 'Scoring profile not found', 404);
|
||||
}
|
||||
json_success(['profile' => $profile]);
|
||||
}
|
||||
json_success(['profiles' => qdb_list_scoring_profiles($pdo)]);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'List scoring profiles', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
$name = trim($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
json_error('BAD_REQUEST', 'name is required', 400);
|
||||
}
|
||||
$bands = qdb_parse_scoring_bands_body($body);
|
||||
$bandErr = qdb_validate_scoring_bands($bands);
|
||||
if ($bandErr !== null) {
|
||||
json_error('INVALID_FIELD', $bandErr, 400);
|
||||
}
|
||||
$members = qdb_parse_profile_questionnaires($body['questionnaires'] ?? []);
|
||||
if ($members === []) {
|
||||
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
||||
}
|
||||
$processCompleteBands = qdb_parse_process_complete_bands($body['processCompleteBands'] ?? []);
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$profileID = bin2hex(random_bytes(16));
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt)
|
||||
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :pcb, :ca, :ua)'
|
||||
)->execute([
|
||||
':id' => $profileID,
|
||||
':name' => $name,
|
||||
':desc' => trim($body['description'] ?? ''),
|
||||
':act' => !empty($body['isActive']) ? 1 : 0,
|
||||
':gmin' => $bands['greenMin'],
|
||||
':gmax' => $bands['greenMax'],
|
||||
':ymin' => $bands['yellowMin'],
|
||||
':ymax' => $bands['yellowMax'],
|
||||
':rmin' => $bands['redMin'],
|
||||
':pcb' => qdb_encode_process_complete_bands($processCompleteBands),
|
||||
':ca' => $now,
|
||||
':ua' => $now,
|
||||
]);
|
||||
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
|
||||
qdb_recompute_all_profiles_after_change($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Create scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
$profileID = trim($body['profileID'] ?? '');
|
||||
if ($profileID === '') {
|
||||
json_error('BAD_REQUEST', 'profileID is required', 400);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$existing = qdb_get_scoring_profile($pdo, $profileID);
|
||||
if (!$existing) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Scoring profile not found', 404);
|
||||
}
|
||||
$name = trim($body['name'] ?? $existing['name']);
|
||||
$bands = qdb_normalize_scoring_bands(array_merge($existing, $body));
|
||||
$bandErr = qdb_validate_scoring_bands($bands);
|
||||
if ($bandErr !== null) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $bandErr, 400);
|
||||
}
|
||||
$members = isset($body['questionnaires'])
|
||||
? qdb_parse_profile_questionnaires($body['questionnaires'])
|
||||
: null;
|
||||
if ($members !== null && $members === []) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
||||
}
|
||||
$processCompleteBands = array_key_exists('processCompleteBands', $body)
|
||||
? qdb_parse_process_complete_bands($body['processCompleteBands'])
|
||||
: qdb_row_process_complete_bands($existing);
|
||||
$pdo->prepare(
|
||||
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
|
||||
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
|
||||
processCompleteBands = :pcb, updatedAt = :ua WHERE profileID = :id'
|
||||
)->execute([
|
||||
':name' => $name,
|
||||
':desc' => trim($body['description'] ?? $existing['description']),
|
||||
':act' => isset($body['isActive']) ? (!empty($body['isActive']) ? 1 : 0) : $existing['isActive'],
|
||||
':gmin' => $bands['greenMin'],
|
||||
':gmax' => $bands['greenMax'],
|
||||
':ymin' => $bands['yellowMin'],
|
||||
':ymax' => $bands['yellowMax'],
|
||||
':rmin' => $bands['redMin'],
|
||||
':pcb' => qdb_encode_process_complete_bands($processCompleteBands),
|
||||
':ua' => time(),
|
||||
':id' => $profileID,
|
||||
]);
|
||||
if ($members !== null) {
|
||||
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
|
||||
}
|
||||
qdb_recompute_all_profiles_after_change($pdo, $profileID);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Update scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
$profileID = trim($body['profileID'] ?? '');
|
||||
if ($profileID === '') {
|
||||
json_error('BAD_REQUEST', 'profileID is required', 400);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $profileID]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Delete scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionnaireID: string, weight: float, orderIndex: int}>
|
||||
*/
|
||||
function qdb_parse_profile_questionnaires(array $rows): array {
|
||||
$out = [];
|
||||
$idx = 0;
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$qnID = trim((string)($row['questionnaireID'] ?? ''));
|
||||
if ($qnID === '') {
|
||||
continue;
|
||||
}
|
||||
$weight = (float)($row['weight'] ?? 1.0);
|
||||
if ($weight <= 0) {
|
||||
$weight = 1.0;
|
||||
}
|
||||
$out[] = [
|
||||
'questionnaireID' => $qnID,
|
||||
'weight' => $weight,
|
||||
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
|
||||
];
|
||||
$idx++;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_sync_profile_questionnaires(PDO $pdo, string $profileID, array $members): void {
|
||||
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
|
||||
->execute([':pid' => $profileID]);
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES (:pid, :qn, :w, :o)'
|
||||
);
|
||||
foreach ($members as $m) {
|
||||
$ins->execute([
|
||||
':pid' => $profileID,
|
||||
':qn' => $m['questionnaireID'],
|
||||
':w' => $m['weight'],
|
||||
':o' => $m['orderIndex'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_recompute_all_profiles_after_change(PDO $pdo, ?string $profileID = null): void {
|
||||
$clients = $pdo->query('SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = \'completed\'')
|
||||
->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($clients as $clientCode) {
|
||||
qdb_recompute_profile_scores_for_client($pdo, (string)$clientCode);
|
||||
}
|
||||
}
|
||||
44
handlers/session.php
Normal file
44
handlers/session.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GET /api/session — verify Bearer token is still active and return role metadata.
|
||||
*/
|
||||
|
||||
if ($method !== 'GET') {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
$token = get_bearer_token();
|
||||
if (!$token) {
|
||||
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
|
||||
}
|
||||
|
||||
$rec = token_get_record($token);
|
||||
if (!$rec) {
|
||||
json_error('UNAUTHORIZED', 'Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
$role = (string)($rec['role'] ?? '');
|
||||
|
||||
$username = '';
|
||||
if (($rec['userID'] ?? '') !== '') {
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
|
||||
$stmt->execute([':uid' => $rec['userID']]);
|
||||
$row = $stmt->fetchColumn();
|
||||
$username = $row !== false ? (string)$row : '';
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Session check', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
json_success([
|
||||
'valid' => true,
|
||||
'user' => $username,
|
||||
'role' => $role,
|
||||
'userID' => $rec['userID'] ?? '',
|
||||
'mustChangePassword' => !empty($rec['temp']),
|
||||
]);
|
||||
70
handlers/settings.php
Normal file
70
handlers/settings.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/settings.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin'], $tokenRec);
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$settings = qdb_settings_get_on_pdo($pdo);
|
||||
$sessionCount = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'settings' => $settings,
|
||||
'activeSessions' => $sessionCount,
|
||||
'defaults' => qdb_settings_defaults(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load settings', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
case 'PATCH':
|
||||
$body = read_json_body();
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$current = qdb_settings_get_on_pdo($pdo);
|
||||
$merged = qdb_settings_validate_and_merge($body, $current);
|
||||
qdb_settings_save_on_pdo($pdo, $merged);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['settings' => $merged]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save settings', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = read_json_body();
|
||||
$action = trim((string)($body['action'] ?? ''));
|
||||
if ($action !== 'revokeAllSessions') {
|
||||
json_error('BAD_REQUEST', 'Unknown action', 400);
|
||||
}
|
||||
$confirm = trim((string)($body['confirmPhrase'] ?? ''));
|
||||
$revokeAllPhrase = 'REVOKE ALL SESSIONS';
|
||||
if ($confirm !== $revokeAllPhrase) {
|
||||
json_error(
|
||||
'CONFIRMATION_REQUIRED',
|
||||
'Type exactly "' . $revokeAllPhrase . '" to revoke every session',
|
||||
400
|
||||
);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$removed = token_revoke_all_on_pdo($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['revokedSessions' => $removed]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Revoke all sessions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
@ -1,96 +1,138 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
$validTypes = ['question', 'answer_option', 'string', 'language'];
|
||||
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language', 'language_enabled', 'questionnaire_language'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if (isset($_GET['languages'])) {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(["languages" => $rows]);
|
||||
if (!empty($_GET['exportBundle'])) {
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$bundle = qdb_export_translations_bundle($pdo);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Export translations', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
|
||||
$filename = 'translations_bundle_' . date('Y-m-d_His') . '.json';
|
||||
qdb_http_download(
|
||||
json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
|
||||
[
|
||||
'Content-Type' => 'application/json; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
if ($qnID) {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
if (isset($_GET['languages'])) {
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$rows = qdb_load_languages($pdo);
|
||||
$disables = qdb_questionnaire_language_disables($pdo);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'languages' => $rows,
|
||||
'questionnaireLanguageDisables' => $disables,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!empty($_GET['all'])) {
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$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);
|
||||
$languages = qdb_load_languages($pdo);
|
||||
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
||||
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German', 'enabled' => 1]);
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
$stringKeys = [];
|
||||
$qnRows = $pdo->query("
|
||||
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$entries[] = ['key' => $dbQ['defaultText'], 'type' => 'question', 'entityId' => $dbQ['questionID']];
|
||||
$appEntries = qdb_app_ui_string_entries_for_website($pdo);
|
||||
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
|
||||
qdb_attach_translation_texts($appEntries, $appTranslations);
|
||||
|
||||
$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']];
|
||||
$questionnaires = [];
|
||||
$allEntries = $appEntries;
|
||||
|
||||
foreach ($qnRows as $qn) {
|
||||
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
|
||||
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
||||
if (!$entries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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;
|
||||
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
||||
qdb_attach_translation_texts($entries, $translations);
|
||||
$questionnaires[] = [
|
||||
'questionnaireID' => $qn['questionnaireID'],
|
||||
'name' => $qn['name'],
|
||||
'orderIndex' => (int)$qn['orderIndex'],
|
||||
'entries' => $entries,
|
||||
];
|
||||
foreach ($entries as $e) {
|
||||
$allEntries[] = $e;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($stringKeys) as $sk) {
|
||||
$entries[] = ['key' => $sk, 'type' => 'string', 'entityId' => $sk];
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'languages' => $languages,
|
||||
'appStrings' => $appEntries,
|
||||
'questionnaires' => $questionnaires,
|
||||
'entries' => $allEntries,
|
||||
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
$translations = [];
|
||||
$qnID = trim((string)($_GET['questionnaireID'] ?? ''));
|
||||
if ($qnID !== '') {
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$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'];
|
||||
$languages = qdb_load_languages($pdo);
|
||||
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
||||
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German', 'enabled' => 1]);
|
||||
}
|
||||
}
|
||||
|
||||
$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'];
|
||||
$qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id');
|
||||
$qnRow->execute([':id' => $qnID]);
|
||||
$qnName = $qnRow->fetchColumn();
|
||||
if ($qnName === false) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
$lists = qdb_translation_entry_lists($pdo, $qnID);
|
||||
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
||||
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
||||
qdb_attach_translation_texts($entries, $translations);
|
||||
|
||||
foreach ($entries as &$e) {
|
||||
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'languages' => $languages,
|
||||
'entries' => $entries,
|
||||
'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName],
|
||||
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
unset($e);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(["languages" => $languages, "entries" => $entries]);
|
||||
}
|
||||
|
||||
$type = $_GET['type'] ?? '';
|
||||
@ -99,8 +141,10 @@ case 'GET':
|
||||
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
if ($type === 'question') {
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
if ($type === 'question') {
|
||||
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
@ -114,9 +158,62 @@ case 'GET':
|
||||
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
|
||||
}
|
||||
}
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(["translations" => $rows]);
|
||||
if ($type === 'question' || $type === 'answer_option') {
|
||||
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Translation entity not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['translations' => $rows]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
if (($body['action'] ?? '') === 'importBundle') {
|
||||
$bundle = $body['bundle'] ?? null;
|
||||
if (!is_array($bundle)) {
|
||||
json_error('MISSING_FIELDS', 'bundle object is required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$result = qdb_import_translations_bundle($pdo, $bundle);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success($result);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Import translations', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (($body['action'] ?? '') === 'deleteTranslationsScope') {
|
||||
$scope = trim((string)($body['scope'] ?? ''));
|
||||
$qnID = trim((string)($body['questionnaireID'] ?? ''));
|
||||
$lang = isset($body['languageCode']) ? trim((string)$body['languageCode']) : '';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$deleted = qdb_delete_translations_for_scope(
|
||||
$pdo,
|
||||
$scope,
|
||||
$qnID !== '' ? $qnID : null,
|
||||
$lang !== '' ? $lang : null
|
||||
);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => $deleted]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Delete translations', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
json_error('BAD_REQUEST', 'Unknown action', 400);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
@ -130,17 +227,64 @@ case 'PUT':
|
||||
if (!$lang) {
|
||||
json_error('MISSING_FIELDS', 'languageCode required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
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]);
|
||||
if ($lang === QDB_SOURCE_LANGUAGE) {
|
||||
qdb_ensure_source_language($pdo);
|
||||
} elseif (qdb_column_exists($pdo, 'language', 'enabled')) {
|
||||
$pdo->prepare('INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) '
|
||||
. qdb_upsert_update(['languageCode'], ['name' => 'excluded.name', 'enabled' => '1']))
|
||||
->execute([':lc' => $lang, ':n' => $name]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) '
|
||||
. qdb_upsert_update(['languageCode'], ['name' => 'excluded.name']))
|
||||
->execute([':lc' => $lang, ':n' => $name]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save language', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($type === 'language_enabled') {
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!$lang) {
|
||||
json_error('MISSING_FIELDS', 'languageCode required', 400);
|
||||
}
|
||||
$enabled = !empty($body['enabled']);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
qdb_set_language_enabled($pdo, $lang, $enabled);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save language setting', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($type === 'questionnaire_language') {
|
||||
$qnID = trim($body['questionnaireID'] ?? '');
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!$qnID || !$lang) {
|
||||
json_error('MISSING_FIELDS', 'questionnaireID and languageCode required', 400);
|
||||
}
|
||||
$enabled = !empty($body['enabled']);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
qdb_set_questionnaire_language_disabled($pdo, $qnID, $lang, !$enabled);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save questionnaire language setting', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -152,30 +296,18 @@ case 'PUT':
|
||||
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
if ($type === 'question') {
|
||||
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
} else {
|
||||
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Translation entity not found', 404);
|
||||
}
|
||||
qdb_put_translation($pdo, $type, $id, $lang, $text);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Save translation', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -189,7 +321,10 @@ case 'DELETE':
|
||||
if (!$lang) {
|
||||
json_error('MISSING_FIELDS', 'languageCode required', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
if ($lang === QDB_SOURCE_LANGUAGE) {
|
||||
json_error('BAD_REQUEST', 'German (de) cannot be removed', 400);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
@ -198,9 +333,7 @@ case 'DELETE':
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Delete language', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -211,24 +344,29 @@ case 'DELETE':
|
||||
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$opened = qdb_open_write_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
if ($type === 'question' || $type === 'answer_option') {
|
||||
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Translation entity not found', 404);
|
||||
}
|
||||
}
|
||||
if ($type === 'question') {
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang")
|
||||
$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")
|
||||
$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")
|
||||
$pdo->prepare('DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang')
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'Delete translation', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
@ -10,7 +9,7 @@ switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
|
||||
if ($callerRole === 'admin') {
|
||||
$users = $pdo->query(
|
||||
@ -54,9 +53,7 @@ case 'GET':
|
||||
'callerRole' => $callerRole,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -89,7 +86,7 @@ case 'POST':
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
|
||||
$chk->execute([':u' => $username]);
|
||||
@ -143,7 +140,7 @@ case 'POST':
|
||||
|
||||
$svUsername = null;
|
||||
if ($role === 'coach') {
|
||||
[$pdo2, $tmp2, $lk2] = qdb_open(false);
|
||||
[$pdo2, $tmp2, $lk2] = qdb_open_read_or_fail();
|
||||
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
|
||||
$svr->execute([':sid' => $supervisorID]);
|
||||
$svUsername = $svr->fetchColumn() ?: null;
|
||||
@ -162,10 +159,208 @@ case 'POST':
|
||||
'createdAt' => $now,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
case 'PATCH':
|
||||
$body = read_json_body();
|
||||
$targetUserID = trim($body['userID'] ?? '');
|
||||
$action = trim((string)($body['action'] ?? ''));
|
||||
$newPassword = (string)($body['password'] ?? $body['newPassword'] ?? '');
|
||||
|
||||
if ($action === 'revokeSessions') {
|
||||
if ($targetUserID === '') {
|
||||
json_error('MISSING_FIELDS', 'userID is required', 400);
|
||||
}
|
||||
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
|
||||
json_error('FORBIDDEN', 'Use Admin settings to revoke your own other sessions, or sign out', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$row = $pdo->prepare('SELECT userID, username, role, entityID FROM users WHERE userID = :uid');
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
$target = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$target) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'User not found', 404);
|
||||
}
|
||||
|
||||
$targetRole = $target['role'] ?? '';
|
||||
if ($callerRole === 'admin') {
|
||||
if (!in_array($targetRole, ['admin', 'supervisor', 'coach'], true)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Cannot revoke sessions for this user', 403);
|
||||
}
|
||||
} elseif ($callerRole === 'supervisor') {
|
||||
if ($targetRole !== 'coach') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Supervisors may only revoke sessions for their coaches', 403);
|
||||
}
|
||||
$chk = $pdo->prepare(
|
||||
'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid'
|
||||
);
|
||||
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
|
||||
}
|
||||
} else {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Not allowed to revoke sessions', 403);
|
||||
}
|
||||
|
||||
$removed = token_revoke_all_for_user_on_pdo($pdo, $targetUserID);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
json_success([
|
||||
'userID' => $targetUserID,
|
||||
'username' => $target['username'],
|
||||
'revokedSessions' => $removed,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Revoke user sessions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($newPassword !== '') {
|
||||
if ($targetUserID === '') {
|
||||
json_error('MISSING_FIELDS', 'userID is required', 400);
|
||||
}
|
||||
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
|
||||
json_error('FORBIDDEN', 'Cannot reset your own password', 400);
|
||||
}
|
||||
if (strlen($newPassword) < 6) {
|
||||
json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400);
|
||||
}
|
||||
$mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1;
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$row = $pdo->prepare(
|
||||
"SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID
|
||||
FROM users u
|
||||
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
|
||||
WHERE u.userID = :uid"
|
||||
);
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
$target = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$target) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'User not found', 404);
|
||||
}
|
||||
|
||||
$targetRole = $target['role'] ?? '';
|
||||
if ($callerRole === 'admin') {
|
||||
if (!in_array($targetRole, ['supervisor', 'coach'], true)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Admins may only reset passwords for supervisors and coaches', 403);
|
||||
}
|
||||
} elseif ($callerRole === 'supervisor') {
|
||||
if ($targetRole !== 'coach') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Supervisors may only reset passwords for their coaches', 403);
|
||||
}
|
||||
$chk = $pdo->prepare(
|
||||
'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid'
|
||||
);
|
||||
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
|
||||
}
|
||||
} else {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Not allowed to reset passwords', 403);
|
||||
}
|
||||
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$pdo->prepare(
|
||||
'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid'
|
||||
)->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]);
|
||||
|
||||
token_revoke_all_for_user_on_pdo($pdo, $targetUserID);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
json_success([
|
||||
'userID' => $targetUserID,
|
||||
'username' => $target['username'],
|
||||
'mustChangePassword' => $mustChange,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($callerRole !== 'admin') {
|
||||
json_error('FORBIDDEN', 'Only admins can reassign coaches', 403);
|
||||
}
|
||||
|
||||
$supervisorID = trim($body['supervisorID'] ?? '');
|
||||
|
||||
if ($targetUserID === '' || $supervisorID === '') {
|
||||
json_error('MISSING_FIELDS', 'userID and supervisorID are required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$row = $pdo->prepare(
|
||||
"SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID
|
||||
FROM users u
|
||||
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
|
||||
WHERE u.userID = :uid"
|
||||
);
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
$target = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$target) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'User not found', 404);
|
||||
}
|
||||
if (($target['role'] ?? '') !== 'coach') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', 'Only coaches can be reassigned to a supervisor', 400);
|
||||
}
|
||||
|
||||
$svCheck = $pdo->prepare('SELECT username FROM supervisor WHERE supervisorID = :sid');
|
||||
$svCheck->execute([':sid' => $supervisorID]);
|
||||
$svUsername = $svCheck->fetchColumn();
|
||||
if ($svUsername === false) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Supervisor not found', 404);
|
||||
}
|
||||
|
||||
if (($target['supervisorID'] ?? '') === $supervisorID) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'userID' => $targetUserID,
|
||||
'supervisorID' => $supervisorID,
|
||||
'supervisorUsername' => $svUsername,
|
||||
]);
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE coach SET supervisorID = :sid WHERE coachID = :cid')
|
||||
->execute([':sid' => $supervisorID, ':cid' => $target['entityID']]);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
json_success([
|
||||
'userID' => $targetUserID,
|
||||
'supervisorID' => $supervisorID,
|
||||
'supervisorUsername' => $svUsername,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -181,7 +376,7 @@ case 'DELETE':
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
@ -201,7 +396,7 @@ case 'DELETE':
|
||||
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Coach is not under your supervision', 403);
|
||||
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,10 +418,7 @@ case 'DELETE':
|
||||
|
||||
json_success([]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Server error', 500);
|
||||
qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@ -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,174 +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();
|
||||
require_role(['admin'], $tokenRec);
|
||||
}
|
||||
|
||||
function out(string $msg, bool $isCli): void {
|
||||
if ($isCli) { echo $msg . "\n"; }
|
||||
}
|
||||
|
||||
$assetsDir = __DIR__ . '/website/temp-assets';
|
||||
$orderFile = $assetsDir . '/questionnaire_order.json';
|
||||
|
||||
$orderData = json_decode(file_get_contents($orderFile), true);
|
||||
if (!$orderData) {
|
||||
$err = "Could not read questionnaire_order.json";
|
||||
if ($isCli) { die($err . "\n"); }
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => $err]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
foreach ($orderData as $orderIdx => $entry) {
|
||||
$filename = $entry['file'];
|
||||
$showPoints = (int)($entry['showPoints'] ?? 0);
|
||||
$condition = json_encode($entry['condition'] ?? new stdClass());
|
||||
|
||||
$jsonPath = $assetsDir . '/' . $filename;
|
||||
$qData = json_decode(file_get_contents($jsonPath), true);
|
||||
if (!$qData) {
|
||||
throw new Exception("Could not parse $filename");
|
||||
}
|
||||
|
||||
$questionnaireID = $qData['meta']['id'];
|
||||
$name = str_replace('_', ' ', preg_replace('/^questionnaire_\d+_/', '', $questionnaireID));
|
||||
$name = ucwords($name);
|
||||
|
||||
out("Importing: $questionnaireID ($name)", $isCli);
|
||||
|
||||
$pdo->prepare("INSERT OR REPLACE INTO questionnaire
|
||||
(questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
|
||||
->execute([
|
||||
':id' => $questionnaireID,
|
||||
':n' => $name,
|
||||
':v' => '1.0',
|
||||
':s' => 'active',
|
||||
':o' => $orderIdx,
|
||||
':sp' => $showPoints,
|
||||
':cj' => $condition,
|
||||
]);
|
||||
|
||||
$questions = $qData['questions'] ?? [];
|
||||
foreach ($questions as $qIdx => $q) {
|
||||
$qLocalId = $q['id'];
|
||||
$layout = $q['layout'];
|
||||
$qKey = $q['question'] ?? '';
|
||||
|
||||
$questionID = $questionnaireID . '__' . $qLocalId;
|
||||
|
||||
$config = [];
|
||||
|
||||
if (isset($q['textKey'])) $config['textKey'] = $q['textKey'];
|
||||
if (isset($q['textKey1'])) $config['textKey1'] = $q['textKey1'];
|
||||
if (isset($q['textKey2'])) $config['textKey2'] = $q['textKey2'];
|
||||
if (isset($q['hint'])) $config['hint'] = $q['hint'];
|
||||
if (isset($q['hint1'])) $config['hint1'] = $q['hint1'];
|
||||
if (isset($q['hint2'])) $config['hint2'] = $q['hint2'];
|
||||
if (isset($q['symptoms'])) $config['symptoms'] = $q['symptoms'];
|
||||
if (isset($q['range'])) $config['range'] = $q['range'];
|
||||
if (isset($q['constraints'])) $config['constraints'] = $q['constraints'];
|
||||
if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection'];
|
||||
|
||||
if ($layout === 'string_spinner' && isset($q['options']) && is_array($q['options'])
|
||||
&& isset($q['options'][0]) && is_string($q['options'][0])) {
|
||||
$config['options'] = $q['options'];
|
||||
}
|
||||
|
||||
if ($layout === 'value_spinner' && isset($q['options']) && is_array($q['options'])) {
|
||||
$valueOptions = [];
|
||||
foreach ($q['options'] as $vo) {
|
||||
if (is_array($vo) && isset($vo['value'])) {
|
||||
$valueOptions[] = $vo;
|
||||
}
|
||||
}
|
||||
if ($valueOptions) {
|
||||
$config['valueOptions'] = $valueOptions;
|
||||
}
|
||||
}
|
||||
|
||||
$configJson = !empty($config) ? json_encode($config) : '{}';
|
||||
|
||||
$pdo->prepare("INSERT OR REPLACE INTO question
|
||||
(questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qnid, :dt, :ty, :oi, :ir, :cj)")
|
||||
->execute([
|
||||
':id' => $questionID,
|
||||
':qnid' => $questionnaireID,
|
||||
':dt' => $qKey,
|
||||
':ty' => $layout,
|
||||
':oi' => $qIdx,
|
||||
':ir' => 0,
|
||||
':cj' => $configJson,
|
||||
]);
|
||||
|
||||
if (isset($q['options']) && is_array($q['options'])) {
|
||||
$hasObjects = isset($q['options'][0]) && is_array($q['options'][0]);
|
||||
if ($hasObjects) {
|
||||
$pointsMap = $q['pointsMap'] ?? [];
|
||||
foreach ($q['options'] as $optIdx => $opt) {
|
||||
$optKey = $opt['key'] ?? '';
|
||||
if (!$optKey) continue;
|
||||
|
||||
$nextQId = '';
|
||||
if (isset($opt['nextQuestionId'])) {
|
||||
$nextQId = $opt['nextQuestionId'];
|
||||
}
|
||||
$pts = (int)($pointsMap[$optKey] ?? 0);
|
||||
$answerOptionID = $questionID . '__opt_' . $optIdx;
|
||||
|
||||
$pdo->prepare("INSERT OR REPLACE INTO answer_option
|
||||
(answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
||||
VALUES (:id, :qid, :dt, :p, :oi, :nq)")
|
||||
->execute([
|
||||
':id' => $answerOptionID,
|
||||
':qid' => $questionID,
|
||||
':dt' => $optKey,
|
||||
':p' => $pts,
|
||||
':oi' => $optIdx,
|
||||
':nq' => $nextQId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out(" -> " . count($questions) . " questions imported", $isCli);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
if ($isCli) {
|
||||
echo "\nImport complete.\n";
|
||||
} else {
|
||||
echo json_encode(["success" => true, "message" => "Import complete"]);
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
if ($isCli) {
|
||||
die("ERROR: " . $e->getMessage() . "\n");
|
||||
}
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
834
index.html
834
index.html
@ -1,834 +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-x: auto; border-radius: 8px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 8px 0 24px; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
|
||||
#qdb-table thead tr:nth-child(1) th { position: sticky; top: 0; z-index: 3; background: #f7f7f7; }
|
||||
#qdb-table thead tr:nth-child(2) th { position: sticky; top: var(--head1h, 34px); z-index: 2; background: #f7f7f7; }
|
||||
.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; }
|
||||
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="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.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.overflowY = 'auto';
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const findInput = $('#findClient');
|
||||
const findBtn = $('#findBtn');
|
||||
findBtn.onclick = () => gotoClientRow(findInput.value.trim());
|
||||
findInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); findBtn.click(); }
|
||||
});
|
||||
|
||||
if (table.tHead && table.tHead.rows.length >= 1) {
|
||||
const h1 = table.tHead.rows[0].getBoundingClientRect().height || 34;
|
||||
table.style.setProperty('--head1h', `${Math.round(h1)}px`);
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
/* ---------- 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>
|
||||
436
lib/analytics.php
Normal file
436
lib/analytics.php
Normal file
@ -0,0 +1,436 @@
|
||||
<?php
|
||||
/**
|
||||
* Analytics queries for Insights dashboard (RBAC-scoped).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Daily upload counts for the last N calendar days (zeros for quiet days).
|
||||
*
|
||||
* @return list<array{date: string, count: int}>
|
||||
*/
|
||||
function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days = 14): array {
|
||||
$days = max(1, min(90, $days));
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$startTs = strtotime('today', time()) - ($days - 1) * 86400;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT qs.submittedAt
|
||||
FROM questionnaire_submission qs
|
||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||
WHERE $rbacClause AND qs.submittedAt >= :start"
|
||||
);
|
||||
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
|
||||
$byDate = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $submittedAt) {
|
||||
$key = date('Y-m-d', (int)$submittedAt);
|
||||
$byDate[$key] = ($byDate[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$key = date('Y-m-d', $startTs + $i * 86400);
|
||||
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_analytics_overview(PDO $pdo, array $tokenRec): array {
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM client cl WHERE $rbacClause");
|
||||
$stmt->execute($rbacParams);
|
||||
$clientCount = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
|
||||
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
||||
WHERE $rbacClause"
|
||||
);
|
||||
$stmt->execute($rbacParams);
|
||||
$clientsWithCompletions = (int)$stmt->fetchColumn();
|
||||
|
||||
$now = time();
|
||||
$d7 = $now - 7 * 86400;
|
||||
$d30 = $now - 30 * 86400;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM questionnaire_submission qs
|
||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||
WHERE $rbacClause AND qs.submittedAt >= :t"
|
||||
);
|
||||
$stmt->execute(array_merge($rbacParams, [':t' => $d7]));
|
||||
$submissions7d = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM questionnaire_submission qs
|
||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||
WHERE $rbacClause AND qs.submittedAt >= :t"
|
||||
);
|
||||
$stmt->execute(array_merge($rbacParams, [':t' => $d30]));
|
||||
$submissions30d = (int)$stmt->fetchColumn();
|
||||
|
||||
$qnRows = $pdo->query(
|
||||
"SELECT questionnaireID, name, orderIndex FROM questionnaire WHERE state = 'active' ORDER BY orderIndex, name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$perQuestionnaire = [];
|
||||
foreach ($qnRows as $qn) {
|
||||
$qnID = $qn['questionnaireID'];
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
|
||||
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
||||
WHERE cq.questionnaireID = :qn AND $rbacClause"
|
||||
);
|
||||
$stmt->execute(array_merge([':qn' => $qnID], $rbacParams));
|
||||
$completed = (int)$stmt->fetchColumn();
|
||||
$ratio = $clientCount > 0 ? round(100 * $completed / $clientCount, 1) : 0;
|
||||
$perQuestionnaire[] = [
|
||||
'questionnaireID' => $qnID,
|
||||
'name' => $qn['name'],
|
||||
'completedCount' => $completed,
|
||||
'eligibleCount' => $clientCount,
|
||||
'completionRatio' => $ratio,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'clientCount' => $clientCount,
|
||||
'clientsWithCompletions' => $clientsWithCompletions,
|
||||
'submissionsLast7d' => $submissions7d,
|
||||
'submissionsLast30d' => $submissions30d,
|
||||
'questionnaires' => $perQuestionnaire,
|
||||
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
|
||||
'scoringProfiles' => qdb_analytics_scoring_profiles($pdo, $tokenRec),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Band distribution and averages per active scoring profile (RBAC-scoped).
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_analytics_scoring_profiles(PDO $pdo, array $tokenRec): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$profiles = $pdo->query(
|
||||
"SELECT profileID, name, description,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, isActive
|
||||
FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$out = [];
|
||||
foreach ($profiles as $p) {
|
||||
$profileID = $p['profileID'];
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.band, r.coachBand, r.weightedTotal
|
||||
FROM client_scoring_profile_result r
|
||||
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||
WHERE r.profileID = :pid AND ($rbacClause)"
|
||||
);
|
||||
$stmt->execute(array_merge([':pid' => $profileID], $rbacParams));
|
||||
$computedBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
|
||||
$coachBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
|
||||
$pendingReview = 0;
|
||||
$totalSum = 0.0;
|
||||
$count = 0;
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$calcBand = $row['band'] ?? '';
|
||||
if (isset($computedBands[$calcBand])) {
|
||||
$computedBands[$calcBand]++;
|
||||
}
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
if ($coachBand === '') {
|
||||
$pendingReview++;
|
||||
} elseif (isset($coachBands[$coachBand])) {
|
||||
$coachBands[$coachBand]++;
|
||||
}
|
||||
$totalSum += (float)$row['weightedTotal'];
|
||||
$count++;
|
||||
}
|
||||
$members = qdb_scoring_profile_members($pdo, $profileID);
|
||||
$bandRanges = qdb_normalize_scoring_bands($p);
|
||||
$out[] = [
|
||||
'profileID' => $profileID,
|
||||
'name' => $p['name'],
|
||||
'description' => $p['description'] ?? '',
|
||||
'greenMin' => $bandRanges['greenMin'],
|
||||
'greenMax' => $bandRanges['greenMax'],
|
||||
'yellowMin' => $bandRanges['yellowMin'],
|
||||
'yellowMax' => $bandRanges['yellowMax'],
|
||||
'redMin' => $bandRanges['redMin'],
|
||||
'questionnaires' => $members,
|
||||
'clientCount' => $count,
|
||||
'bands' => $computedBands,
|
||||
'computedBands' => $computedBands,
|
||||
'coachBands' => $coachBands,
|
||||
'pendingReview' => $pendingReview,
|
||||
'averageTotal' => $count > 0 ? round($totalSum / $count, 2) : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$now = time();
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode,
|
||||
co.username AS coachUsername,
|
||||
co.coachID,
|
||||
lc.lastCompletedAt,
|
||||
lc.lastQuestionnaireID,
|
||||
qn.name AS lastQuestionnaireName,
|
||||
la.lastActivityAt,
|
||||
n.note AS followupNote
|
||||
FROM client cl
|
||||
INNER JOIN (
|
||||
SELECT cq.clientCode, cq.questionnaireID AS lastQuestionnaireID, cq.completedAt AS lastCompletedAt
|
||||
FROM completed_questionnaire cq
|
||||
WHERE cq.completedAt IS NOT NULL
|
||||
AND cq.rowid = (
|
||||
SELECT c2.rowid FROM completed_questionnaire c2
|
||||
WHERE c2.clientCode = cq.clientCode AND c2.completedAt IS NOT NULL
|
||||
ORDER BY c2.completedAt DESC, c2.questionnaireID DESC
|
||||
LIMIT 1
|
||||
)
|
||||
) lc ON lc.clientCode = cl.clientCode
|
||||
LEFT JOIN questionnaire qn ON qn.questionnaireID = lc.lastQuestionnaireID
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
LEFT JOIN (
|
||||
SELECT qs.clientCode, MAX(qs.submittedAt) AS lastActivityAt
|
||||
FROM questionnaire_submission qs
|
||||
GROUP BY qs.clientCode
|
||||
) la ON la.clientCode = cl.clientCode
|
||||
LEFT JOIN client_followup_note n ON n.clientCode = cl.clientCode
|
||||
WHERE $rbacClause
|
||||
ORDER BY COALESCE(la.lastActivityAt, 0) ASC, cl.clientCode ASC
|
||||
";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($rbacParams);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$completedTs = $r['lastCompletedAt'] ? (int)$r['lastCompletedAt'] : null;
|
||||
$uploadTs = $r['lastActivityAt'] !== null ? (int)$r['lastActivityAt'] : null;
|
||||
$lastAt = null;
|
||||
if ($completedTs !== null && $uploadTs !== null) {
|
||||
$lastAt = max($completedTs, $uploadTs);
|
||||
} elseif ($completedTs !== null) {
|
||||
$lastAt = $completedTs;
|
||||
} else {
|
||||
$lastAt = $uploadTs;
|
||||
}
|
||||
$daysSince = $lastAt !== null
|
||||
? (int)floor(($now - $lastAt) / 86400)
|
||||
: null;
|
||||
$out[] = [
|
||||
'clientCode' => $r['clientCode'],
|
||||
'coachUsername' => $r['coachUsername'] ?? $r['coachID'],
|
||||
'lastQuestionnaireID' => $r['lastQuestionnaireID'],
|
||||
'lastQuestionnaireName' => $r['lastQuestionnaireName'] ?? $r['lastQuestionnaireID'],
|
||||
'lastCompletedAt' => $completedTs ? date('Y-m-d H:i', $completedTs) : '',
|
||||
'daysSinceLastActivity' => $daysSince,
|
||||
'note' => $r['followupNote'] ?? '',
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Max length for counselor/admin client follow-up notes (characters). */
|
||||
const QDB_CLIENT_NOTE_MAX_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Sanitize a client follow-up note. Empty string clears the note.
|
||||
* Rejects input that sanitizes to empty while the raw value was non-empty.
|
||||
*/
|
||||
function qdb_normalize_client_followup_note(mixed $note): string {
|
||||
require_once __DIR__ . '/text_sanitize.php';
|
||||
$raw = is_string($note) || is_numeric($note) ? trim((string)$note) : '';
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
if (mb_strlen($raw) > QDB_CLIENT_NOTE_MAX_LENGTH) {
|
||||
json_error(
|
||||
'INVALID_FIELD',
|
||||
'note must be at most ' . QDB_CLIENT_NOTE_MAX_LENGTH . ' characters',
|
||||
400
|
||||
);
|
||||
}
|
||||
$clean = sanitize_free_text($raw, QDB_CLIENT_NOTE_MAX_LENGTH);
|
||||
if ($clean === '') {
|
||||
json_error('INVALID_FIELD', 'note contains disallowed content', 400);
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a follow-up note for a client visible under the caller's RBAC.
|
||||
* Pass an already-normalized note (see qdb_normalize_client_followup_note).
|
||||
*/
|
||||
function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void {
|
||||
$clientCode = trim($clientCode);
|
||||
if ($clientCode === '') {
|
||||
json_error('INVALID_FIELD', 'clientCode is required', 400);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)");
|
||||
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||
if (!$stmt->fetchColumn()) {
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt)
|
||||
VALUES (:cc, :n, :uid, :ts) '
|
||||
. qdb_upsert_update(
|
||||
['clientCode'],
|
||||
[
|
||||
'note' => 'excluded.note',
|
||||
'updatedByUserID' => 'excluded.updatedByUserID',
|
||||
'updatedAt' => 'excluded.updatedAt',
|
||||
]
|
||||
)
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':n' => $note,
|
||||
':uid' => $tokenRec['userID'] ?? '',
|
||||
':ts' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
|
||||
$role = $tokenRec['role'] ?? '';
|
||||
$entityID = $tokenRec['entityID'] ?? '';
|
||||
|
||||
if ($role === 'supervisor') {
|
||||
$coachWhere = 'co.supervisorID = :eid';
|
||||
$coachParams = [':eid' => $entityID];
|
||||
} else {
|
||||
$coachWhere = '1=1';
|
||||
$coachParams = [];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$d7 = $now - 7 * 86400;
|
||||
$d30 = $now - 30 * 86400;
|
||||
|
||||
$sql = "
|
||||
SELECT co.coachID, co.username,
|
||||
sv.username AS supervisorUsername,
|
||||
(SELECT COUNT(*) FROM client c
|
||||
WHERE c.coachID = co.coachID AND COALESCE(c.archived, 0) = 0) AS clientCount,
|
||||
(SELECT COUNT(*) FROM questionnaire_submission qs
|
||||
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||
WHERE c.coachID = co.coachID) AS totalSubmissions,
|
||||
(SELECT COUNT(*) FROM questionnaire_submission qs
|
||||
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d7) AS submissions7d,
|
||||
(SELECT COUNT(*) FROM questionnaire_submission qs
|
||||
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d30) AS submissions30d,
|
||||
(SELECT MAX(qs.submittedAt) FROM questionnaire_submission qs
|
||||
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||
WHERE c.coachID = co.coachID) AS lastSubmissionAt
|
||||
FROM coach co
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||
WHERE $coachWhere
|
||||
ORDER BY co.username
|
||||
";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([':d7' => $d7, ':d30' => $d30], $coachParams));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$last = $r['lastSubmissionAt'] !== null ? (int)$r['lastSubmissionAt'] : null;
|
||||
$out[] = [
|
||||
'coachID' => $r['coachID'],
|
||||
'username' => $r['username'],
|
||||
'supervisorUsername'=> $r['supervisorUsername'] ?? '',
|
||||
'clientCount' => (int)$r['clientCount'],
|
||||
'totalSubmissions' => (int)$r['totalSubmissions'],
|
||||
'submissionsLast7d' => (int)$r['submissions7d'],
|
||||
'submissionsLast30d'=> (int)$r['submissions30d'],
|
||||
'lastSubmissionAt' => $last ? date('Y-m-d H:i', $last) : '',
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_coach_recent_submissions(
|
||||
PDO $pdo,
|
||||
array $tokenRec,
|
||||
string $coachID,
|
||||
int $limit = 100,
|
||||
int $offset = 0
|
||||
): array {
|
||||
$coachID = trim($coachID);
|
||||
if ($coachID === '') {
|
||||
json_error('INVALID_FIELD', 'coachID is required', 400);
|
||||
}
|
||||
|
||||
$role = $tokenRec['role'] ?? '';
|
||||
$entityID = $tokenRec['entityID'] ?? '';
|
||||
|
||||
$chk = $pdo->prepare('SELECT coachID, supervisorID FROM coach WHERE coachID = :id');
|
||||
$chk->execute([':id' => $coachID]);
|
||||
$coach = $chk->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$coach) {
|
||||
json_error('NOT_FOUND', 'Counselor not found', 404);
|
||||
}
|
||||
if ($role === 'supervisor' && ($coach['supervisorID'] ?? '') !== $entityID) {
|
||||
json_error('FORBIDDEN', 'Not authorized for this coach', 403);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$limit = max(1, min($limit, 200));
|
||||
$offset = max(0, $offset);
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM questionnaire_submission qs
|
||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||
WHERE cl.coachID = :cid AND ($rbacClause)
|
||||
";
|
||||
$countStmt = $pdo->prepare($countSql);
|
||||
$countStmt->execute(array_merge([':cid' => $coachID], $rbacParams));
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
|
||||
$sql = "
|
||||
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole,
|
||||
qs.clientCode, qs.questionnaireID, qn.name AS questionnaireName
|
||||
FROM questionnaire_submission qs
|
||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||
LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID
|
||||
WHERE cl.coachID = :cid AND ($rbacClause)
|
||||
ORDER BY qs.submittedAt DESC
|
||||
LIMIT $limit OFFSET $offset
|
||||
";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([':cid' => $coachID], $rbacParams));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$submissions = array_map(static function ($r) {
|
||||
return [
|
||||
'submissionID' => $r['submissionID'],
|
||||
'version' => (int)$r['version'],
|
||||
'submittedAt' => $r['submittedAt'] ? date('Y-m-d H:i', (int)$r['submittedAt']) : '',
|
||||
'submittedByRole' => $r['submittedByRole'] ?? '',
|
||||
'clientCode' => $r['clientCode'],
|
||||
'questionnaireID' => $r['questionnaireID'],
|
||||
'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'submissions' => $submissions,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
998
lib/api_log.php
Normal file
998
lib/api_log.php
Normal file
@ -0,0 +1,998 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Activity API logs (JSON Lines). One file per calendar day; rotates when a file
|
||||
* exceeds QDB_API_LOG_MAX_BYTES (default 10 MiB) to api-YYYY-MM-DD-002.log, etc.
|
||||
*
|
||||
* Recorded activity (not routine website page loads):
|
||||
* - app_sync: GET /app_questionnaires (mobile sync)
|
||||
* - app_change: POST/PUT/PATCH/DELETE from the Android app
|
||||
* - web_change: POST/PUT/PATCH/DELETE from the management website (incl. translation labels)
|
||||
* - web_export: Website downloads (CSV/ZIP/JSON exports, translation bundle)
|
||||
*
|
||||
* Set QDB_API_LOG=0 to disable. Set QDB_API_LOG_DIR for a custom path.
|
||||
* Default directory is uploads/logs/api (writable by PHP alongside the database).
|
||||
*/
|
||||
|
||||
/** @var list<string> */
|
||||
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export', 'api_error'];
|
||||
|
||||
function qdb_api_log_enabled(): bool
|
||||
{
|
||||
$v = qdb_env_get('QDB_API_LOG');
|
||||
if ($v === null || $v === '') {
|
||||
return true;
|
||||
}
|
||||
$v = strtolower(trim($v));
|
||||
return !in_array($v, ['0', 'false', 'no', 'off'], true);
|
||||
}
|
||||
|
||||
function qdb_api_log_dir(): string
|
||||
{
|
||||
static $resolved = null;
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$custom = qdb_env_get('QDB_API_LOG_DIR');
|
||||
if ($custom !== null && $custom !== '') {
|
||||
$resolved = rtrim($custom, '/\\');
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
foreach ([$root . '/uploads/logs/api', $root . '/logs/api'] as $candidate) {
|
||||
if (qdb_api_log_dir_is_usable($candidate)) {
|
||||
$resolved = $candidate;
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = $root . '/uploads/logs/api';
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
function qdb_api_log_dir_is_usable(string $dir): bool
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
return is_writable($dir);
|
||||
}
|
||||
$parent = dirname($dir);
|
||||
if (is_dir($parent) && is_writable($parent)) {
|
||||
return true;
|
||||
}
|
||||
if (@mkdir($dir, 0775, true)) {
|
||||
return is_writable($dir);
|
||||
}
|
||||
return is_dir($dir) && is_writable($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for admin UI (permissions, path, enabled flag).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_api_log_status(): array
|
||||
{
|
||||
$dir = qdb_api_log_dir();
|
||||
$enabled = qdb_api_log_enabled();
|
||||
$exists = is_dir($dir);
|
||||
$writable = false;
|
||||
$probeError = null;
|
||||
|
||||
if ($enabled) {
|
||||
if (!$exists) {
|
||||
if (!@mkdir($dir, 0775, true)) {
|
||||
$probeError = 'Cannot create directory: ' . $dir;
|
||||
}
|
||||
$exists = is_dir($dir);
|
||||
}
|
||||
if ($exists && !is_writable($dir)) {
|
||||
$probeError = 'Directory exists but is not writable by PHP: ' . $dir;
|
||||
} elseif ($exists) {
|
||||
$probe = $dir . '/.write_probe';
|
||||
if (@file_put_contents($probe, (string)time() . "\n", LOCK_EX) === false) {
|
||||
$probeError = 'Cannot write files in: ' . $dir;
|
||||
} else {
|
||||
$writable = true;
|
||||
@unlink($probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$phpUser = null;
|
||||
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
|
||||
$info = posix_getpwuid(posix_geteuid());
|
||||
if (is_array($info) && !empty($info['name'])) {
|
||||
$phpUser = (string)$info['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'dir' => $dir,
|
||||
'exists' => $exists,
|
||||
'writable' => $writable,
|
||||
'error' => $probeError,
|
||||
'php_user' => $phpUser,
|
||||
'fix_hint' => $writable || !$enabled
|
||||
? null
|
||||
: 'On the server run: mkdir -p ' . $dir . ' && chown -R www-data:www-data '
|
||||
. dirname($dir) . ' && chmod -R 775 ' . dirname($dir)
|
||||
. ' (adjust www-data to your PHP user). Or set QDB_API_LOG_DIR in .env to a writable path.',
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_api_log_max_bytes(): int
|
||||
{
|
||||
$v = qdb_env_get('QDB_API_LOG_MAX_BYTES');
|
||||
if ($v !== null && $v !== '' && ctype_digit($v)) {
|
||||
return max(1024 * 1024, (int)$v);
|
||||
}
|
||||
return 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
function qdb_api_log_body_max_chars(): int
|
||||
{
|
||||
$v = qdb_env_get('QDB_API_LOG_BODY_MAX');
|
||||
if ($v !== null && $v !== '' && ctype_digit($v)) {
|
||||
return max(256, (int)$v);
|
||||
}
|
||||
return 4096;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request should be written to the activity log.
|
||||
*/
|
||||
function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string
|
||||
{
|
||||
$method = strtoupper($method);
|
||||
$client = strtolower(trim($client));
|
||||
$route = strtolower(trim($route));
|
||||
|
||||
if ($method === 'OPTIONS' || in_array($route, ['activity-log', 'session'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
||||
return $client === 'web' ? 'web_change' : 'app_change';
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($route === 'app_questionnaires' && $client !== 'web') {
|
||||
return 'app_sync';
|
||||
}
|
||||
if (qdb_api_log_is_website_download($route)) {
|
||||
return 'web_export';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Website GET that downloads or exports data (not routine list/load). */
|
||||
function qdb_api_log_is_website_download(string $route): bool
|
||||
{
|
||||
if (in_array($route, ['export', 'backup'], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($route === 'translations' && !empty($_GET['exportBundle'])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
function qdb_api_log_begin(string $method, string $route, array $context = []): void
|
||||
{
|
||||
if (!qdb_api_log_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
|
||||
$activity = qdb_api_log_classify_activity($method, $client, $route);
|
||||
|
||||
$GLOBALS['qdb_api_log_ctx'] = array_merge([
|
||||
'started_at' => microtime(true),
|
||||
'method' => strtoupper($method),
|
||||
'route' => $route,
|
||||
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
|
||||
'ip' => qdb_api_log_client_ip(),
|
||||
'client' => $client,
|
||||
'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')),
|
||||
], $context);
|
||||
|
||||
if ($activity !== null) {
|
||||
$GLOBALS['qdb_api_log_ctx']['activity'] = $activity;
|
||||
} else {
|
||||
$GLOBALS['qdb_api_log_ctx']['skip_success'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_note_exception(Throwable $e): void
|
||||
{
|
||||
if (empty($GLOBALS['qdb_api_log_ctx'])) {
|
||||
return;
|
||||
}
|
||||
$GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage();
|
||||
$GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class;
|
||||
}
|
||||
|
||||
function qdb_api_log_note_api_error(string $code, string $message, int $status): void
|
||||
{
|
||||
if (empty($GLOBALS['qdb_api_log_ctx'])) {
|
||||
return;
|
||||
}
|
||||
$GLOBALS['qdb_api_log_ctx']['api_error_code'] = $code;
|
||||
$GLOBALS['qdb_api_log_ctx']['api_error_message'] = $message;
|
||||
$GLOBALS['qdb_api_log_ctx']['api_error_status'] = $status;
|
||||
}
|
||||
|
||||
function qdb_api_log_finish(): void
|
||||
{
|
||||
$ctx = $GLOBALS['qdb_api_log_ctx'] ?? null;
|
||||
unset($GLOBALS['qdb_api_log_ctx']);
|
||||
if (!is_array($ctx) || !qdb_api_log_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$started = (float)($ctx['started_at'] ?? microtime(true));
|
||||
$status = http_response_code();
|
||||
if ($status === false || $status === 0) {
|
||||
$status = 200;
|
||||
}
|
||||
|
||||
$isError = $status >= 400
|
||||
|| !empty($ctx['exception'])
|
||||
|| !empty($ctx['api_error_code']);
|
||||
|
||||
if (!empty($ctx['skip_success']) && !$isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = get_bearer_token();
|
||||
$auth = ['role' => null, 'userID' => null, 'token_hint' => null];
|
||||
if ($token !== null && $token !== '') {
|
||||
$auth['token_hint'] = qdb_api_log_token_hint($token);
|
||||
try {
|
||||
$rec = token_get_record($token, false);
|
||||
if (is_array($rec)) {
|
||||
$auth['role'] = $rec['role'] ?? null;
|
||||
$auth['userID'] = $rec['userID'] ?? null;
|
||||
if (!empty($rec['temp'])) {
|
||||
$auth['temp_token'] = true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$auth['token_lookup'] = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
$rawBody = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : '';
|
||||
$bodyLog = qdb_api_log_format_body($rawBody);
|
||||
$queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? ''));
|
||||
|
||||
$actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null);
|
||||
$targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog);
|
||||
|
||||
$activity = $ctx['activity'] ?? null;
|
||||
if ($isError) {
|
||||
$activity = 'api_error';
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'ts' => date('c'),
|
||||
'activity' => $activity,
|
||||
'method' => $ctx['method'] ?? '',
|
||||
'route' => $ctx['route'] ?? '',
|
||||
'status' => $status,
|
||||
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
|
||||
'ip' => $ctx['ip'] ?? '',
|
||||
'client' => $ctx['client'] ?? '',
|
||||
'uri' => $ctx['uri'] ?? '',
|
||||
'query' => $queryLog,
|
||||
'role' => $auth['role'],
|
||||
'userID' => $auth['userID'],
|
||||
'actor_username' => $actorUsername,
|
||||
'target_username' => $targetUsername,
|
||||
'token_hint' => $auth['token_hint'],
|
||||
'temp_token' => $auth['temp_token'] ?? null,
|
||||
'changes' => qdb_api_log_build_changes(
|
||||
(string)($ctx['method'] ?? ''),
|
||||
(string)($ctx['route'] ?? ''),
|
||||
$bodyLog,
|
||||
$queryLog
|
||||
),
|
||||
];
|
||||
|
||||
if (!empty($ctx['api_error_code'])) {
|
||||
$entry['error_code'] = (string)$ctx['api_error_code'];
|
||||
$entry['error_message'] = (string)($ctx['api_error_message'] ?? '');
|
||||
$entry['error'] = $entry['error_code'] . ': ' . $entry['error_message'];
|
||||
}
|
||||
if (!empty($ctx['exception'])) {
|
||||
$entry['error_internal'] = (string)$ctx['exception'];
|
||||
if (!empty($ctx['exception_class'])) {
|
||||
$entry['error_class'] = (string)$ctx['exception_class'];
|
||||
}
|
||||
if (empty($entry['error'])) {
|
||||
$entry['error'] = 'SERVER_ERROR: ' . $entry['error_internal'];
|
||||
}
|
||||
}
|
||||
|
||||
if (($ctx['user_agent'] ?? '') !== '') {
|
||||
$entry['user_agent'] = $ctx['user_agent'];
|
||||
}
|
||||
|
||||
try {
|
||||
$line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($line === false) {
|
||||
return;
|
||||
}
|
||||
qdb_api_log_append_line($line . "\n");
|
||||
} catch (Throwable $e) {
|
||||
error_log('api_log write failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_client_ip(): string
|
||||
{
|
||||
foreach ([
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR',
|
||||
] as $key) {
|
||||
$v = trim((string)($_SERVER[$key] ?? ''));
|
||||
if ($v === '') {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'HTTP_X_FORWARDED_FOR') {
|
||||
$v = trim(explode(',', $v)[0]);
|
||||
}
|
||||
if (filter_var($v, FILTER_VALIDATE_IP)) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function qdb_api_log_token_hint(string $token): string
|
||||
{
|
||||
$len = strlen($token);
|
||||
if ($len <= 8) {
|
||||
return '[token]';
|
||||
}
|
||||
return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)";
|
||||
}
|
||||
|
||||
function qdb_api_log_redact_query_string(string $qs): ?string
|
||||
{
|
||||
if ($qs === '') {
|
||||
return null;
|
||||
}
|
||||
parse_str($qs, $params);
|
||||
if (!is_array($params) || $params === []) {
|
||||
return $qs;
|
||||
}
|
||||
foreach ($params as $k => $_) {
|
||||
if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) {
|
||||
$params[$k] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
return http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|string|null
|
||||
*/
|
||||
function qdb_api_log_format_body(string $raw): array|string|null
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = qdb_api_log_body_max_chars();
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
$redacted = qdb_api_log_redact_value($decoded);
|
||||
$json = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return '[unencodable body]';
|
||||
}
|
||||
if (strlen($json) > $max) {
|
||||
return substr($json, 0, $max) . '…[truncated]';
|
||||
}
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
if (strlen($raw) > $max) {
|
||||
return substr($raw, 0, $max) . '…[truncated]';
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** @param mixed $value @return mixed */
|
||||
function qdb_api_log_redact_value(mixed $value): mixed
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$isList = array_keys($value) === range(0, count($value) - 1);
|
||||
$out = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$lk = strtolower((string)$k);
|
||||
if (in_array($lk, [
|
||||
'password', 'old_password', 'new_password', 'passwordhash',
|
||||
'token', 'authorization', 'secret',
|
||||
], true)) {
|
||||
$out[$k] = '[REDACTED]';
|
||||
} elseif ($lk === 'encrypted' && is_string($v)) {
|
||||
$out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]';
|
||||
} elseif (is_array($v)) {
|
||||
$out[$k] = qdb_api_log_redact_value($v);
|
||||
} else {
|
||||
$out[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $isList ? array_values($out) : $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-level change list for the activity log UI (passwords stay redacted).
|
||||
*
|
||||
* @param mixed $body Redacted decoded body or null
|
||||
* @return list<array{field: string, value: string|int|float|bool|null}>
|
||||
*/
|
||||
function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array
|
||||
{
|
||||
$changes = [];
|
||||
$max = 100;
|
||||
|
||||
if ($queryString !== null && $queryString !== '') {
|
||||
parse_str($queryString, $params);
|
||||
if (is_array($params)) {
|
||||
foreach ($params as $k => $v) {
|
||||
if (count($changes) >= $max) {
|
||||
break;
|
||||
}
|
||||
if (!is_scalar($v)) {
|
||||
continue;
|
||||
}
|
||||
$changes[] = [
|
||||
'field' => 'query.' . (string)$k,
|
||||
'value' => qdb_api_log_change_scalar($v),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($body)) {
|
||||
qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max);
|
||||
} elseif (is_string($body) && $body !== '') {
|
||||
$changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)];
|
||||
}
|
||||
|
||||
if ($changes === [] && strtoupper($method) === 'DELETE') {
|
||||
$changes[] = ['field' => '_action', 'value' => 'delete'];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{field: string, value: string|int|float|bool|null}> $out
|
||||
*/
|
||||
function qdb_api_log_flatten_for_changes(
|
||||
array $data,
|
||||
string $prefix,
|
||||
array &$out,
|
||||
int $depth,
|
||||
int $maxDepth,
|
||||
int $maxFields
|
||||
): void {
|
||||
if ($depth >= $maxDepth || count($out) >= $maxFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data as $k => $v) {
|
||||
if (count($out) >= $maxFields) {
|
||||
return;
|
||||
}
|
||||
$key = (string)$k;
|
||||
$field = $prefix === '' ? $key : $prefix . '.' . $key;
|
||||
|
||||
if (is_array($v)) {
|
||||
$childList = array_keys($v) === range(0, count($v) - 1);
|
||||
$n = count($v);
|
||||
if ($n === 0) {
|
||||
$out[] = ['field' => $field, 'value' => '[]'];
|
||||
continue;
|
||||
}
|
||||
if ($childList && $n > 8) {
|
||||
$out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]'];
|
||||
continue;
|
||||
}
|
||||
if (!$childList && $depth + 1 >= $maxDepth) {
|
||||
$out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]'];
|
||||
continue;
|
||||
}
|
||||
qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields);
|
||||
} else {
|
||||
$out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string|int|float|bool|null */
|
||||
function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null
|
||||
{
|
||||
if ($v === null) {
|
||||
return null;
|
||||
}
|
||||
if (is_bool($v) || is_int($v) || is_float($v)) {
|
||||
return $v;
|
||||
}
|
||||
$s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||
if ($s === false) {
|
||||
return '';
|
||||
}
|
||||
if (strlen($s) > 800) {
|
||||
return substr($s, 0, 800) . '…';
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
function qdb_api_log_append_line(string $line): void
|
||||
{
|
||||
$dir = qdb_api_log_dir();
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
$err = error_get_last();
|
||||
throw new RuntimeException(
|
||||
'Cannot create log directory: ' . $dir
|
||||
. ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
|
||||
);
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
throw new RuntimeException('Log directory not writable: ' . $dir);
|
||||
}
|
||||
|
||||
$path = qdb_api_log_resolve_write_path($dir);
|
||||
if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) {
|
||||
$err = error_get_last();
|
||||
throw new RuntimeException(
|
||||
'Cannot write API log: ' . $path
|
||||
. ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_resolve_write_path(string $dir): string
|
||||
{
|
||||
$date = date('Y-m-d');
|
||||
$base = $dir . '/api-' . $date;
|
||||
$max = qdb_api_log_max_bytes();
|
||||
|
||||
$primary = $base . '.log';
|
||||
if (!is_file($primary) || filesize($primary) < $max) {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
for ($part = 2; $part <= 999; $part++) {
|
||||
$path = sprintf('%s-%03d.log', $base, $part);
|
||||
if (!is_file($path) || filesize($path) < $max) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf('%s-%03d.log', $base, 999);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read activity log entries for admin UI (newest first).
|
||||
*
|
||||
* @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool}
|
||||
*/
|
||||
function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200, bool $errorsOnly = false): array
|
||||
{
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
$limit = max(1, min(1000, $limit));
|
||||
$activityFilter = strtolower(trim($activityFilter));
|
||||
if ($activityFilter === 'errors') {
|
||||
$errorsOnly = true;
|
||||
$activityFilter = '';
|
||||
}
|
||||
if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) {
|
||||
$activityFilter = '';
|
||||
}
|
||||
|
||||
$dir = qdb_api_log_dir();
|
||||
$files = qdb_api_log_files_for_date($dir, $date);
|
||||
$entries = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($lines as $line) {
|
||||
$row = json_decode($line, true);
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) {
|
||||
continue;
|
||||
}
|
||||
if ($errorsOnly) {
|
||||
$rowActivity = $row['activity'] ?? '';
|
||||
$rowStatus = (int)($row['status'] ?? 0);
|
||||
if ($rowActivity !== 'api_error' && $rowStatus < 400 && empty($row['error'])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$entries[] = qdb_api_log_entry_for_ui($row);
|
||||
}
|
||||
}
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
return strcmp($b['ts'] ?? '', $a['ts'] ?? '');
|
||||
});
|
||||
|
||||
$truncated = count($entries) > $limit;
|
||||
if ($truncated) {
|
||||
$entries = array_slice($entries, 0, $limit);
|
||||
}
|
||||
|
||||
$entries = qdb_api_log_enrich_entries_usernames($entries);
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'activity' => $activityFilter,
|
||||
'errorsOnly' => $errorsOnly,
|
||||
'entries' => $entries,
|
||||
'files' => array_map('basename', $files),
|
||||
'truncated' => $truncated,
|
||||
'kinds' => QDB_API_LOG_ACTIVITIES,
|
||||
'logStatus' => qdb_api_log_status(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
function qdb_api_log_files_for_date(string $dir, string $date): array
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
$pattern = $dir . '/api-' . $date . '*.log';
|
||||
$files = glob($pattern) ?: [];
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row @return array<string, mixed> */
|
||||
function qdb_api_log_entry_for_ui(array $row): array
|
||||
{
|
||||
$changes = $row['changes'] ?? null;
|
||||
if (!is_array($changes) || $changes === []) {
|
||||
$changes = qdb_api_log_build_changes(
|
||||
(string)($row['method'] ?? ''),
|
||||
(string)($row['route'] ?? ''),
|
||||
$row['body'] ?? null,
|
||||
isset($row['query']) ? (string)$row['query'] : null
|
||||
);
|
||||
}
|
||||
$changes = qdb_api_log_sort_changes_for_display($changes);
|
||||
|
||||
$actorUsername = $row['actor_username'] ?? null;
|
||||
$targetUsername = $row['target_username'] ?? null;
|
||||
|
||||
return [
|
||||
'ts' => $row['ts'] ?? '',
|
||||
'activity' => $row['activity'] ?? '',
|
||||
'method' => $row['method'] ?? '',
|
||||
'route' => $row['route'] ?? '',
|
||||
'status' => $row['status'] ?? null,
|
||||
'duration_ms' => $row['duration_ms'] ?? null,
|
||||
'role' => $row['role'] ?? null,
|
||||
'userID' => $row['userID'] ?? null,
|
||||
'actor_username' => is_string($actorUsername) ? $actorUsername : null,
|
||||
'target_username' => is_string($targetUsername) ? $targetUsername : null,
|
||||
'client' => $row['client'] ?? '',
|
||||
'ip' => $row['ip'] ?? '',
|
||||
'query' => $row['query'] ?? null,
|
||||
'changes' => $changes,
|
||||
'summary' => qdb_api_log_summarize_changes($changes, $targetUsername),
|
||||
'error' => $row['error'] ?? null,
|
||||
'error_code' => $row['error_code'] ?? null,
|
||||
'error_message' => $row['error_message'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_api_log_lookup_username(?string $userID): ?string
|
||||
{
|
||||
if ($userID === null || $userID === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
|
||||
$stmt->execute([':uid' => $userID]);
|
||||
$name = $stmt->fetchColumn();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return $name !== false ? (string)$name : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string
|
||||
{
|
||||
if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) {
|
||||
return trim((string)$body['username']);
|
||||
}
|
||||
if ($queryString !== null && $queryString !== '') {
|
||||
parse_str($queryString, $params);
|
||||
if (!empty($params['username']) && is_scalar($params['username'])) {
|
||||
return trim((string)$params['username']);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve userID / supervisorID in log rows to usernames for retraceability.
|
||||
*
|
||||
* @param list<array<string, mixed>> $entries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_api_log_enrich_entries_usernames(array $entries): array
|
||||
{
|
||||
if ($entries === []) {
|
||||
return $entries;
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
$supervisorIds = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (!empty($entry['userID']) && is_string($entry['userID'])) {
|
||||
$userIds[$entry['userID']] = true;
|
||||
}
|
||||
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
|
||||
$userIds[$entry['userID']] = true;
|
||||
}
|
||||
foreach ($entry['changes'] ?? [] as $change) {
|
||||
if (!is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
$field = strtolower((string)($change['field'] ?? ''));
|
||||
$value = $change['value'] ?? null;
|
||||
if (!is_string($value) || $value === '' || str_starts_with($value, '[')) {
|
||||
continue;
|
||||
}
|
||||
if ($field === 'userid' || str_ends_with($field, '.userid')) {
|
||||
$userIds[$value] = true;
|
||||
}
|
||||
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
|
||||
$supervisorIds[$value] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$userMap = qdb_api_log_load_user_map(array_keys($userIds));
|
||||
$svMap = qdb_api_log_load_supervisor_map(array_keys($supervisorIds));
|
||||
|
||||
foreach ($entries as $i => $entry) {
|
||||
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
|
||||
$entry['actor_username'] = $userMap[$entry['userID']] ?? null;
|
||||
}
|
||||
if (empty($entry['target_username'])) {
|
||||
$entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []);
|
||||
}
|
||||
|
||||
$enriched = [];
|
||||
foreach ($entry['changes'] ?? [] as $change) {
|
||||
if (!is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
$change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap);
|
||||
$enriched[] = $change;
|
||||
}
|
||||
$entry['changes'] = qdb_api_log_sort_changes_for_display($enriched);
|
||||
$entry['summary'] = qdb_api_log_summarize_changes(
|
||||
$entry['changes'],
|
||||
$entry['target_username'] ?? null
|
||||
);
|
||||
$entries[$i] = $entry;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** @param list<string> $userIds @return array<string, string> */
|
||||
function qdb_api_log_load_user_map(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_filter($userIds, static fn($id) => $id !== ''));
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT userID, username FROM users WHERE userID IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($userIds);
|
||||
$map = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$map[(string)$row['userID']] = (string)$row['username'];
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return $map;
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $supervisorIds @return array<string, string> */
|
||||
function qdb_api_log_load_supervisor_map(array $supervisorIds): array
|
||||
{
|
||||
$supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== ''));
|
||||
if ($supervisorIds === []) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$placeholders = implode(',', array_fill(0, count($supervisorIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($supervisorIds);
|
||||
$map = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$map[(string)$row['supervisorID']] = (string)$row['username'];
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return $map;
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array{field?: string, value?: mixed}> $changes */
|
||||
function qdb_api_log_target_from_changes(array $changes): ?string
|
||||
{
|
||||
foreach ($changes as $c) {
|
||||
$field = strtolower((string)($c['field'] ?? ''));
|
||||
if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) {
|
||||
return trim((string)$c['value']);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $userMap
|
||||
* @param array<string, string> $svMap
|
||||
*/
|
||||
function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string
|
||||
{
|
||||
$field = strtolower((string)($change['field'] ?? ''));
|
||||
$value = $change['value'] ?? null;
|
||||
if ($value === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (!is_scalar($value)) {
|
||||
return '';
|
||||
}
|
||||
$str = (string)$value;
|
||||
|
||||
if ($field === 'username') {
|
||||
return $str;
|
||||
}
|
||||
if ($field === 'userid' || str_ends_with($field, '.userid')) {
|
||||
return $userMap[$str] ?? $str;
|
||||
}
|
||||
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
|
||||
return $svMap[$str] ?? $str;
|
||||
}
|
||||
if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) {
|
||||
return $str;
|
||||
}
|
||||
if ($field === 'mustchangepassword') {
|
||||
return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)';
|
||||
}
|
||||
if ($field === 'password' || str_contains($field, 'password')) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{field?: string, value?: mixed}> $changes
|
||||
* @return list<array{field?: string, value?: mixed}>
|
||||
*/
|
||||
function qdb_api_log_sort_changes_for_display(array $changes): array
|
||||
{
|
||||
$priority = [
|
||||
'username' => 0,
|
||||
'type' => 1,
|
||||
'text' => 2,
|
||||
'languagecode' => 3,
|
||||
'id' => 4,
|
||||
'role' => 5,
|
||||
'clientcode' => 6,
|
||||
'action' => 7,
|
||||
'query.id' => 10,
|
||||
'query.clients' => 11,
|
||||
'query.clientcode' => 12,
|
||||
'query.answers' => 13,
|
||||
'query.translations' => 14,
|
||||
'query.bundle' => 15,
|
||||
'query.exportall' => 16,
|
||||
'query.exportbundle' => 17,
|
||||
'supervisorid' => 18,
|
||||
'userid' => 19,
|
||||
'mustchangepassword' => 20,
|
||||
'password' => 21,
|
||||
'location' => 22,
|
||||
];
|
||||
|
||||
usort($changes, static function (array $a, array $b) use ($priority): int {
|
||||
$fa = strtolower((string)($a['field'] ?? ''));
|
||||
$fb = strtolower((string)($b['field'] ?? ''));
|
||||
$pa = $priority[$fa] ?? 50;
|
||||
$pb = $priority[$fb] ?? 50;
|
||||
if ($pa !== $pb) {
|
||||
return $pa <=> $pb;
|
||||
}
|
||||
return strcmp($fa, $fb);
|
||||
});
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/** @param list<array{field: string, value: mixed, display?: string}> $changes */
|
||||
function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string
|
||||
{
|
||||
if ($targetUsername !== null && $targetUsername !== '') {
|
||||
return 'user=' . $targetUsername;
|
||||
}
|
||||
if ($changes === []) {
|
||||
return '';
|
||||
}
|
||||
$parts = [];
|
||||
foreach (array_slice($changes, 0, 8) as $c) {
|
||||
$field = (string)($c['field'] ?? '');
|
||||
if ($field === '') {
|
||||
continue;
|
||||
}
|
||||
$label = (string)($c['display'] ?? '');
|
||||
$value = $c['value'] ?? '';
|
||||
if ($label !== '' && $label !== (string)$value) {
|
||||
$parts[] = $field . '=' . $label;
|
||||
} elseif ($value === null) {
|
||||
$parts[] = $field . '=null';
|
||||
} elseif (is_bool($value)) {
|
||||
$parts[] = $field . '=' . ($value ? 'true' : 'false');
|
||||
} else {
|
||||
$parts[] = $field . '=' . (string)$value;
|
||||
}
|
||||
}
|
||||
$summary = implode(', ', $parts);
|
||||
if (count($changes) > 8) {
|
||||
$summary .= ', …';
|
||||
}
|
||||
if (strlen($summary) > 240) {
|
||||
$summary = substr($summary, 0, 240) . '…';
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
306
lib/app_answers.php
Normal file
306
lib/app_answers.php
Normal file
@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* Mobile app answer ingest/export helpers (submit payload ↔ client_answer rows).
|
||||
*/
|
||||
|
||||
/** Encode multi-select keys the same way the Android app stores them locally. */
|
||||
function qdb_encode_multi_check_values(array $keys): string {
|
||||
$clean = [];
|
||||
foreach ($keys as $k) {
|
||||
$k = trim((string)$k);
|
||||
if ($k !== '') {
|
||||
$clean[$k] = true;
|
||||
}
|
||||
}
|
||||
return json_encode(array_keys($clean), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
function qdb_decode_multi_check_values(?string $raw): array {
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed[0] === '[') {
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$out = [];
|
||||
foreach ($decoded as $v) {
|
||||
$s = trim((string)$v);
|
||||
if ($s !== '') {
|
||||
$out[] = $s;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
$sep = str_contains($trimmed, ',') ? ',' : (str_contains($trimmed, ';') ? ';' : null);
|
||||
if ($sep !== null) {
|
||||
$parts = [];
|
||||
foreach (explode($sep, $trimmed) as $p) {
|
||||
$s = trim($p, " \t\n\r\0\x0B\"'");
|
||||
if ($s !== '') {
|
||||
$parts[] = $s;
|
||||
}
|
||||
}
|
||||
return $parts;
|
||||
}
|
||||
return [$trimmed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build glass-scale symptom JSON from the current submit only (no merge with prior rows).
|
||||
*
|
||||
* @param array<string, string> $symptomLabels symptom key => selected label
|
||||
*/
|
||||
function qdb_build_glass_symptom_json(array $symptomLabels): ?string {
|
||||
$data = [];
|
||||
foreach ($symptomLabels as $key => $label) {
|
||||
$k = trim((string)$key);
|
||||
$v = trim((string)$label);
|
||||
if ($k !== '' && $v !== '') {
|
||||
$data[$k] = $v;
|
||||
}
|
||||
}
|
||||
if ($data === []) {
|
||||
return null;
|
||||
}
|
||||
return json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw POST answer rows by question short id; merge multi_check option keys.
|
||||
*
|
||||
* @param array<string, string> $shortIdToType question localId => layout type
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_group_app_submit_answers(array $answers, array $shortIdToType, array $symptomParentMap): array {
|
||||
$grouped = [];
|
||||
$order = [];
|
||||
|
||||
foreach ($answers as $a) {
|
||||
if (!is_array($a)) {
|
||||
continue;
|
||||
}
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($symptomParentMap[$shortId])) {
|
||||
$order[] = $shortId;
|
||||
$grouped[$shortId] = $a;
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $shortIdToType[$shortId] ?? '';
|
||||
if ($type === 'multi_check_box_question') {
|
||||
if (!isset($grouped[$shortId])) {
|
||||
$order[] = $shortId;
|
||||
$grouped[$shortId] = [
|
||||
'questionID' => $shortId,
|
||||
'multiOptionKeys' => [],
|
||||
'answeredAt' => $a['answeredAt'] ?? null,
|
||||
];
|
||||
}
|
||||
$key = trim((string)($a['answerOptionKey'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$grouped[$shortId]['multiOptionKeys'][$key] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$order[] = $shortId;
|
||||
$grouped[$shortId] = $a;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($order as $shortId) {
|
||||
$row = $grouped[$shortId];
|
||||
if (!empty($row['multiOptionKeys'])) {
|
||||
$keys = array_keys($row['multiOptionKeys']);
|
||||
$out[] = [
|
||||
'questionID' => $shortId,
|
||||
'freeTextValue' => qdb_encode_multi_check_values($keys),
|
||||
'answeredAt' => $row['answeredAt'] ?? null,
|
||||
];
|
||||
} else {
|
||||
unset($row['multiOptionKeys']);
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export one client's answers for one questionnaire into app submit shape.
|
||||
*
|
||||
* @return array{answers: list<array<string, mixed>>, sumPoints: int, completedAt: ?int}
|
||||
*/
|
||||
function qdb_export_app_questionnaire_answers(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $qnID
|
||||
): array {
|
||||
$qStmt = $pdo->prepare(
|
||||
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn"
|
||||
);
|
||||
$qStmt->execute([':qn' => $qnID]);
|
||||
$shortToFull = [];
|
||||
$fullToShort = [];
|
||||
$fullToType = [];
|
||||
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
|
||||
|
||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$full = $row['questionID'];
|
||||
$parts = explode('__', $full);
|
||||
$short = end($parts);
|
||||
$shortToFull[$short] = $full;
|
||||
$fullToShort[$full] = $short;
|
||||
$fullToType[$full] = $row['type'] ?? '';
|
||||
}
|
||||
|
||||
$aoStmt = $pdo->prepare("
|
||||
SELECT ao.answerOptionID, ao.questionID, ao.defaultText
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn
|
||||
");
|
||||
$aoStmt->execute([':qn' => $qnID]);
|
||||
$optionIdToKey = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionIdToKey[$ao['answerOptionID']] = $ao['defaultText'];
|
||||
}
|
||||
|
||||
$caStmt = $pdo->prepare("
|
||||
SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue, ca.answeredAt
|
||||
FROM client_answer ca
|
||||
JOIN question q ON q.questionID = ca.questionID
|
||||
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn
|
||||
");
|
||||
$caStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$rows = $caStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$export = [];
|
||||
foreach ($rows as $row) {
|
||||
$fullQid = $row['questionID'];
|
||||
$shortId = $fullToShort[$fullQid] ?? '';
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
$type = $fullToType[$fullQid] ?? '';
|
||||
|
||||
if ($type === 'glass_scale_question' && ($row['freeTextValue'] ?? '') !== '') {
|
||||
$decoded = json_decode($row['freeTextValue'], true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $symptomKey => $label) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
$lab = trim((string)$label);
|
||||
if ($sk !== '' && $lab !== '') {
|
||||
$export[] = [
|
||||
'questionID' => $sk,
|
||||
'answerOptionKey' => $lab,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'multi_check_box_question') {
|
||||
foreach (qdb_decode_multi_check_values($row['freeTextValue'] ?? null) as $key) {
|
||||
$export[] = [
|
||||
'questionID' => $shortId,
|
||||
'answerOptionKey' => $key,
|
||||
];
|
||||
}
|
||||
if ($export === [] && !empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
|
||||
$export[] = [
|
||||
'questionID' => $shortId,
|
||||
'answerOptionKey' => $optionIdToKey[$row['answerOptionID']],
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['questionID' => $shortId];
|
||||
if (!empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
|
||||
$entry['answerOptionKey'] = $optionIdToKey[$row['answerOptionID']];
|
||||
}
|
||||
if (isset($row['freeTextValue']) && $row['freeTextValue'] !== null && $row['freeTextValue'] !== '') {
|
||||
$entry['freeTextValue'] = $row['freeTextValue'];
|
||||
}
|
||||
if (isset($row['numericValue']) && $row['numericValue'] !== null && $row['numericValue'] !== '') {
|
||||
$entry['numericValue'] = (float)$row['numericValue'];
|
||||
}
|
||||
if (!empty($row['answeredAt'])) {
|
||||
$entry['answeredAt'] = (int)$row['answeredAt'];
|
||||
}
|
||||
$export[] = $entry;
|
||||
}
|
||||
|
||||
$cq = $pdo->prepare(
|
||||
"SELECT sumPoints, completedAt FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn"
|
||||
);
|
||||
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$meta = $cq->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
return [
|
||||
'answers' => $export,
|
||||
'sumPoints' => isset($meta['sumPoints']) ? (int)$meta['sumPoints'] : 0,
|
||||
'completedAt' => isset($meta['completedAt']) ? (int)$meta['completedAt'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all completed questionnaires for a client (coach RBAC must be applied by caller).
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): array {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc"
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode]);
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) {
|
||||
$block = qdb_export_app_questionnaire_answers($pdo, $clientCode, $qnID);
|
||||
$items[] = [
|
||||
'questionnaireID' => $qnID,
|
||||
'completedAt' => $block['completedAt'],
|
||||
'sumPoints' => $block['sumPoints'],
|
||||
'answers' => $block['answers'],
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC).
|
||||
*
|
||||
* @return list<array{clientCode: string, questionnaires: list<array<string, mixed>>}>
|
||||
*/
|
||||
function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array {
|
||||
if (($tokenRec['role'] ?? '') !== 'coach') {
|
||||
return [];
|
||||
}
|
||||
$coachID = trim((string)($tokenRec['entityID'] ?? ''));
|
||||
if ($coachID === '') {
|
||||
return [];
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode'
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $clientCode) {
|
||||
$out[] = [
|
||||
'clientCode' => $clientCode,
|
||||
'questionnaires' => qdb_export_app_client_answers_bundle($pdo, (string)$clientCode),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
221
lib/app_submit_validate.php
Normal file
221
lib/app_submit_validate.php
Normal file
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/questionnaire_structure.php';
|
||||
|
||||
/**
|
||||
* Validate Android app questionnaire submit payloads before persisting answers.
|
||||
*
|
||||
* @param array<string, mixed>|null $manifest Structure manifest for legacy revision submits
|
||||
* @return list<array{questionID: string, code: string, message: string}>
|
||||
*/
|
||||
function qdb_validate_app_submit_payload(
|
||||
PDO $pdo,
|
||||
string $qnID,
|
||||
array $rawAnswers,
|
||||
array $shortIdMap,
|
||||
array $shortIdToType,
|
||||
array $symptomParentMap,
|
||||
array $optionMap,
|
||||
?array $manifest = null
|
||||
): array {
|
||||
$errors = [];
|
||||
|
||||
if ($rawAnswers === []) {
|
||||
$errors[] = [
|
||||
'questionID' => '',
|
||||
'code' => 'ANSWERS_REQUIRED',
|
||||
'message' => 'At least one answer is required',
|
||||
];
|
||||
return $errors;
|
||||
}
|
||||
|
||||
foreach ($rawAnswers as $idx => $a) {
|
||||
if (!is_array($a)) {
|
||||
$errors[] = [
|
||||
'questionID' => '',
|
||||
'code' => 'INVALID_ANSWER',
|
||||
'message' => 'Answer at index ' . $idx . ' must be an object',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') {
|
||||
$errors[] = [
|
||||
'questionID' => '',
|
||||
'code' => 'MISSING_QUESTION_ID',
|
||||
'message' => 'Each answer must include questionID',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($symptomParentMap[$shortId])) {
|
||||
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
|
||||
if ($label === '') {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'EMPTY_ANSWER',
|
||||
'message' => 'Symptom selection is required',
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($shortIdMap[$shortId])) {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'UNKNOWN_QUESTION',
|
||||
'message' => 'Unknown question: ' . $shortId,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullQID = $shortIdMap[$shortId];
|
||||
$type = $shortIdToType[$shortId] ?? '';
|
||||
$optKey = $a['answerOptionKey'] ?? null;
|
||||
$free = isset($a['freeTextValue']) ? trim((string)$a['freeTextValue']) : '';
|
||||
$numeric = $a['numericValue'] ?? null;
|
||||
$hasNumeric = $numeric !== null && $numeric !== '';
|
||||
|
||||
if ($type === 'multi_check_box_question') {
|
||||
if ($optKey !== null && trim((string)$optKey) !== '') {
|
||||
continue;
|
||||
}
|
||||
if ($free !== '') {
|
||||
continue;
|
||||
}
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'EMPTY_ANSWER',
|
||||
'message' => 'Select at least one option',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($optKey !== null && trim((string)$optKey) !== '') {
|
||||
$key = (string)$optKey;
|
||||
if (!isset($optionMap[$fullQID][$key])) {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'INVALID_OPTION',
|
||||
'message' => 'Unknown option key: ' . $key,
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($free !== '' || $hasNumeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'EMPTY_ANSWER',
|
||||
'message' => 'Answer is empty',
|
||||
];
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/app_answers.php';
|
||||
$grouped = qdb_group_app_submit_answers($rawAnswers, $shortIdToType, $symptomParentMap);
|
||||
$answeredShort = [];
|
||||
foreach ($grouped as $row) {
|
||||
$sid = trim($row['questionID'] ?? '');
|
||||
if ($sid !== '') {
|
||||
$answeredShort[$sid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest !== null) {
|
||||
return array_merge($errors, qdb_validate_required_answers_from_manifest($manifest, $answeredShort));
|
||||
}
|
||||
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, type, isRequired, configJson FROM question
|
||||
WHERE questionnaireID = :qn AND ' . qdb_active_questions_clause('question')
|
||||
);
|
||||
$qStmt->execute([':qn' => $qnID]);
|
||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||
if ((int)($qRow['isRequired'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$fullId = $qRow['questionID'];
|
||||
$parts = explode('__', $fullId);
|
||||
$short = end($parts);
|
||||
$type = $qRow['type'] ?? '';
|
||||
|
||||
if ($type === 'glass_scale_question') {
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$symptoms = $cfg['symptoms'] ?? [];
|
||||
if (is_array($symptoms) && $symptoms !== []) {
|
||||
foreach ($symptoms as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
if (empty($answeredShort[$sk])) {
|
||||
$errors[] = [
|
||||
'questionID' => $sk,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required symptom is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($answeredShort[$short])) {
|
||||
$errors[] = [
|
||||
'questionID' => $short,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required question is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionID: string, code: string, message: string}>
|
||||
*/
|
||||
function qdb_validate_required_answers_from_manifest(array $manifest, array $answeredShort): array {
|
||||
$errors = [];
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry) || empty($qEntry['isRequired'])) {
|
||||
continue;
|
||||
}
|
||||
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
if ($type === 'glass_scale_question') {
|
||||
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
if (empty($answeredShort[$sk])) {
|
||||
$errors[] = [
|
||||
'questionID' => $sk,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required symptom is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (empty($answeredShort[$shortId])) {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required question is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
74
lib/client_code_range.php
Normal file
74
lib/client_code_range.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @return array{prefix: string, digits: string, suffix: string}
|
||||
*/
|
||||
function qdb_parse_client_code_pattern(string $code): array
|
||||
{
|
||||
if (!preg_match('/\d/u', $code)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Code must contain a numeric segment (e.g. C001 or A01Demo)',
|
||||
);
|
||||
}
|
||||
|
||||
$suffix = '';
|
||||
if (preg_match('/[^0-9]+$/u', $code, $suffixMatch)) {
|
||||
$suffix = $suffixMatch[0];
|
||||
$core = substr($code, 0, -strlen($suffix));
|
||||
} else {
|
||||
$core = $code;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(.*?)(\d+)$/u', $core, $match) || $match[2] === '') {
|
||||
throw new InvalidArgumentException(
|
||||
'Code must contain a numeric segment (e.g. C001 or A01Demo)',
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'prefix' => $match[1],
|
||||
'digits' => $match[2],
|
||||
'suffix' => $suffix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a client-code range such as C001…C050 or A01Demo…A10Demo.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function qdb_expand_client_code_range(string $from, string $to, int $maxCount = 500): array
|
||||
{
|
||||
$fromParts = qdb_parse_client_code_pattern($from);
|
||||
$toParts = qdb_parse_client_code_pattern($to);
|
||||
|
||||
if ($fromParts['prefix'] !== $toParts['prefix'] || $fromParts['suffix'] !== $toParts['suffix']) {
|
||||
throw new InvalidArgumentException(
|
||||
'Prefix and suffix must match between from and to (e.g. C001…C050 or A01Demo…A10Demo)',
|
||||
);
|
||||
}
|
||||
|
||||
$prefix = $fromParts['prefix'];
|
||||
$suffix = $fromParts['suffix'];
|
||||
$padWidth = strlen($fromParts['digits']);
|
||||
$start = (int) $fromParts['digits'];
|
||||
$end = (int) $toParts['digits'];
|
||||
|
||||
if ($start > $end) {
|
||||
throw new InvalidArgumentException('From number must be less than or equal to to');
|
||||
}
|
||||
|
||||
$count = $end - $start + 1;
|
||||
if ($count > $maxCount) {
|
||||
throw new InvalidArgumentException("Range exceeds maximum of $maxCount clients");
|
||||
}
|
||||
|
||||
$codes = [];
|
||||
for ($n = $start; $n <= $end; $n++) {
|
||||
$codes[] = $prefix . str_pad((string) $n, $padWidth, '0', STR_PAD_LEFT) . $suffix;
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
922
lib/dev_fixture.php
Normal file
922
lib/dev_fixture.php
Normal file
@ -0,0 +1,922 @@
|
||||
<?php
|
||||
/**
|
||||
* Dev-only fixture import: users, clients, completed questionnaires.
|
||||
* All entity usernames / client codes must use the fixture prefix (default dev_).
|
||||
*/
|
||||
|
||||
const QDB_DEV_GLASS_LABELS = ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
|
||||
|
||||
const QDB_DEV_GLASS_POINTS = [
|
||||
'never_glass' => 0,
|
||||
'little_glass' => 1,
|
||||
'moderate_glass' => 2,
|
||||
'much_glass' => 3,
|
||||
'extreme_glass' => 4,
|
||||
];
|
||||
|
||||
require_once __DIR__ . '/app_answers.php';
|
||||
require_once __DIR__ . '/submissions.php';
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
|
||||
/**
|
||||
* @return array{imported: array, skipped: array, errors: string[]}
|
||||
*/
|
||||
function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
|
||||
{
|
||||
$prefix = qdb_dev_validate_fixture($fixture);
|
||||
$password = (string)($fixture['defaultPassword'] ?? 'socialvrlab');
|
||||
if (strlen($password) < 6) {
|
||||
json_error('INVALID_FIELD', 'defaultPassword must be at least 6 characters', 400);
|
||||
}
|
||||
|
||||
$imported = [
|
||||
'admins' => 0,
|
||||
'supervisors' => 0,
|
||||
'coaches' => 0,
|
||||
'clients' => 0,
|
||||
'completions' => 0,
|
||||
'submissions' => 0,
|
||||
'answers' => 0,
|
||||
'scoringRecomputed' => 0,
|
||||
];
|
||||
$skipped = [
|
||||
'admins' => 0,
|
||||
'supervisors' => 0,
|
||||
'coaches' => 0,
|
||||
'clients' => 0,
|
||||
'completions' => 0,
|
||||
];
|
||||
$errors = [];
|
||||
|
||||
$coachIdByUsername = [];
|
||||
$supervisorIdByUsername = [];
|
||||
$affectedClients = [];
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$now = time();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($fixture['admins'] ?? [] as $row) {
|
||||
$username = qdb_dev_norm_username($row, $prefix);
|
||||
if (qdb_dev_user_exists($pdo, $username)) {
|
||||
$skipped['admins']++;
|
||||
continue;
|
||||
}
|
||||
$entityID = qdb_dev_entity_id('admin', $username);
|
||||
$userID = qdb_dev_user_id($username);
|
||||
$location = trim($row['location'] ?? 'Dev');
|
||||
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
qdb_dev_insert_user($pdo, $userID, $username, $hash, 'admin', $entityID, $now);
|
||||
$imported['admins']++;
|
||||
}
|
||||
|
||||
foreach ($fixture['supervisors'] ?? [] as $row) {
|
||||
$username = qdb_dev_norm_username($row, $prefix);
|
||||
if (qdb_dev_user_exists($pdo, $username)) {
|
||||
$skipped['supervisors']++;
|
||||
$supervisorIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username);
|
||||
continue;
|
||||
}
|
||||
$entityID = qdb_dev_entity_id('supervisor', $username);
|
||||
$userID = qdb_dev_user_id($username);
|
||||
$location = trim($row['location'] ?? 'Dev');
|
||||
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
qdb_dev_insert_user($pdo, $userID, $username, $hash, 'supervisor', $entityID, $now);
|
||||
$supervisorIdByUsername[$username] = $entityID;
|
||||
$imported['supervisors']++;
|
||||
}
|
||||
|
||||
foreach ($fixture['coaches'] ?? [] as $row) {
|
||||
$username = qdb_dev_norm_username($row, $prefix);
|
||||
$svUser = trim($row['supervisor'] ?? $row['supervisorUsername'] ?? '');
|
||||
if ($svUser === '' || !qdb_dev_has_prefix($svUser, $prefix)) {
|
||||
$errors[] = "Counselor $username: missing or invalid supervisor";
|
||||
continue;
|
||||
}
|
||||
$supervisorID = $supervisorIdByUsername[$svUser]
|
||||
?? qdb_dev_lookup_entity_id($pdo, $svUser);
|
||||
if ($supervisorID === '') {
|
||||
$errors[] = "Counselor $username: supervisor $svUser not found";
|
||||
continue;
|
||||
}
|
||||
if (qdb_dev_user_exists($pdo, $username)) {
|
||||
$skipped['coaches']++;
|
||||
$coachIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username);
|
||||
continue;
|
||||
}
|
||||
$entityID = qdb_dev_entity_id('coach', $username);
|
||||
$userID = qdb_dev_user_id($username);
|
||||
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
|
||||
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
|
||||
qdb_dev_insert_user($pdo, $userID, $username, $hash, 'coach', $entityID, $now);
|
||||
$coachIdByUsername[$username] = $entityID;
|
||||
$imported['coaches']++;
|
||||
}
|
||||
|
||||
foreach ($fixture['clients'] ?? [] as $row) {
|
||||
$clientCode = qdb_dev_norm_client_code($row, $prefix);
|
||||
$coachUser = trim($row['coach'] ?? $row['coachUsername'] ?? '');
|
||||
if ($coachUser === '' || !qdb_dev_has_prefix($coachUser, $prefix)) {
|
||||
$errors[] = "Client $clientCode: missing or invalid coach";
|
||||
continue;
|
||||
}
|
||||
$coachID = $coachIdByUsername[$coachUser]
|
||||
?? qdb_dev_lookup_entity_id($pdo, $coachUser);
|
||||
if ($coachID === '') {
|
||||
$errors[] = "Client $clientCode: coach $coachUser not found";
|
||||
continue;
|
||||
}
|
||||
$chk = $pdo->prepare('SELECT 1 FROM client WHERE clientCode = :cc');
|
||||
$chk->execute([':cc' => $clientCode]);
|
||||
if ($chk->fetch()) {
|
||||
$skipped['clients']++;
|
||||
continue;
|
||||
}
|
||||
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
|
||||
->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||
$imported['clients']++;
|
||||
}
|
||||
|
||||
$variant = 0;
|
||||
foreach ($fixture['completions'] ?? [] as $row) {
|
||||
$clientCode = qdb_dev_norm_client_code($row, $prefix);
|
||||
$qnID = trim($row['questionnaireID'] ?? '');
|
||||
if ($qnID === '') {
|
||||
$errors[] = 'Completion missing questionnaireID';
|
||||
continue;
|
||||
}
|
||||
$qnChk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
|
||||
$qnChk->execute([':id' => $qnID]);
|
||||
if (!$qnChk->fetch()) {
|
||||
$errors[] = "Questionnaire not in DB: $qnID";
|
||||
continue;
|
||||
}
|
||||
$clChk = $pdo->prepare('SELECT coachID FROM client WHERE clientCode = :cc');
|
||||
$clChk->execute([':cc' => $clientCode]);
|
||||
$coachID = $clChk->fetchColumn();
|
||||
if ($coachID === false) {
|
||||
$errors[] = "Client not found: $clientCode";
|
||||
continue;
|
||||
}
|
||||
$doneChk = $pdo->prepare(
|
||||
'SELECT 1 FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$doneChk->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
if ($doneChk->fetch()) {
|
||||
$skipped['completions']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$coachUserStmt = $pdo->prepare(
|
||||
"SELECT u.userID FROM users u
|
||||
WHERE u.role = 'coach' AND u.entityID = :cid LIMIT 1"
|
||||
);
|
||||
$coachUserStmt->execute([':cid' => $coachID]);
|
||||
$coachUserID = (string)($coachUserStmt->fetchColumn() ?: '');
|
||||
$tokenRec = ['userID' => $coachUserID, 'role' => 'coach'];
|
||||
|
||||
$uploads = qdb_dev_normalize_uploads($row, $variant, $now);
|
||||
$archiveSubmissions = qdb_table_exists($pdo, 'questionnaire_submission');
|
||||
|
||||
foreach ($uploads as $uploadIndex => $upload) {
|
||||
$variantSeed = (int)($upload['variant'] ?? ($variant + $uploadIndex));
|
||||
$completedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
|
||||
$startedAt = (int)($upload['startedAt'] ?? ($completedAt - 600));
|
||||
|
||||
$answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variantSeed);
|
||||
$answerCount = qdb_dev_persist_submission(
|
||||
$pdo,
|
||||
$qnID,
|
||||
$clientCode,
|
||||
(string)$coachID,
|
||||
$answers,
|
||||
$startedAt,
|
||||
$completedAt
|
||||
);
|
||||
$imported['answers'] += $answerCount;
|
||||
|
||||
if ($archiveSubmissions) {
|
||||
$spStmt = $pdo->prepare(
|
||||
'SELECT sumPoints FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$spStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$sumPoints = (int)($spStmt->fetchColumn() ?: 0);
|
||||
qdb_record_submission_after_submit(
|
||||
$pdo,
|
||||
$clientCode,
|
||||
$qnID,
|
||||
$tokenRec,
|
||||
$startedAt,
|
||||
$completedAt,
|
||||
$sumPoints,
|
||||
(string)$coachID,
|
||||
$completedAt
|
||||
);
|
||||
$imported['submissions']++;
|
||||
}
|
||||
}
|
||||
|
||||
$imported['completions']++;
|
||||
$affectedClients[$clientCode] = true;
|
||||
$variant++;
|
||||
}
|
||||
|
||||
$upperPrefix = strtoupper(rtrim($prefix, '_'));
|
||||
$devClientStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx');
|
||||
$devClientStmt->execute([':pfx' => $upperPrefix . '%']);
|
||||
foreach ($devClientStmt->fetchAll(PDO::FETCH_COLUMN) as $cc) {
|
||||
$affectedClients[(string)$cc] = true;
|
||||
}
|
||||
if ($affectedClients !== []) {
|
||||
$imported['scoringRecomputed'] = qdb_recompute_scores_for_clients(
|
||||
$pdo,
|
||||
array_keys($affectedClients)
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors];
|
||||
}
|
||||
|
||||
function qdb_dev_validate_fixture(array $fixture): string
|
||||
{
|
||||
$prefix = trim((string)($fixture['prefix'] ?? 'dev_'));
|
||||
if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) {
|
||||
json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400);
|
||||
}
|
||||
$fixtureVersion = (int)($fixture['fixtureVersion'] ?? 0);
|
||||
if ($fixtureVersion !== 1 && $fixtureVersion !== 2) {
|
||||
json_error('INVALID_FIELD', 'fixtureVersion must be 1 or 2', 400);
|
||||
}
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{submittedAt: int, startedAt: int, variant: int}>
|
||||
*/
|
||||
function qdb_dev_normalize_uploads(array $row, int $fallbackVariant, int $now): array
|
||||
{
|
||||
if (!empty($row['uploads']) && is_array($row['uploads'])) {
|
||||
$out = [];
|
||||
foreach ($row['uploads'] as $i => $upload) {
|
||||
if (!is_array($upload)) {
|
||||
continue;
|
||||
}
|
||||
$submittedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
|
||||
$startedAt = (int)($upload['startedAt'] ?? ($submittedAt - 600));
|
||||
$out[] = [
|
||||
'submittedAt' => $submittedAt,
|
||||
'startedAt' => $startedAt,
|
||||
'variant' => (int)($upload['variant'] ?? ($fallbackVariant + $i)),
|
||||
];
|
||||
}
|
||||
if ($out !== []) {
|
||||
usort($out, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
$versionCount = max(1, (int)($row['versions'] ?? $row['uploadCount'] ?? 1));
|
||||
$finalCompleted = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($fallbackVariant % 90) * 86400);
|
||||
$uploads = [];
|
||||
for ($v = 0; $v < $versionCount; $v++) {
|
||||
$gapDays = ($versionCount - 1 - $v) * 14 + ($fallbackVariant % 5);
|
||||
$completedAt = $finalCompleted - ($gapDays * 86400);
|
||||
$uploads[] = [
|
||||
'submittedAt' => $completedAt,
|
||||
'startedAt' => isset($row['startedAt']) && $v === $versionCount - 1
|
||||
? (int)$row['startedAt']
|
||||
: ($completedAt - 600 - ($v * 120)),
|
||||
'variant' => $fallbackVariant + $v,
|
||||
];
|
||||
}
|
||||
usort($uploads, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
function qdb_dev_has_prefix(string $value, string $prefix): bool
|
||||
{
|
||||
return str_starts_with(strtolower($value), strtolower($prefix))
|
||||
|| str_starts_with(strtoupper($value), strtoupper(rtrim($prefix, '_')));
|
||||
}
|
||||
|
||||
function qdb_dev_norm_username(array $row, string $prefix): string
|
||||
{
|
||||
$username = trim($row['username'] ?? '');
|
||||
if ($username === '' || !qdb_dev_has_prefix($username, $prefix)) {
|
||||
json_error('INVALID_FIELD', "Username must use prefix $prefix", 400);
|
||||
}
|
||||
return $username;
|
||||
}
|
||||
|
||||
function qdb_dev_norm_client_code(array $row, string $prefix): string
|
||||
{
|
||||
$code = trim($row['clientCode'] ?? '');
|
||||
$upperPrefix = strtoupper(rtrim($prefix, '_'));
|
||||
if ($code === '' || !str_starts_with($code, $upperPrefix)) {
|
||||
json_error('INVALID_FIELD', "clientCode must start with $upperPrefix", 400);
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
function qdb_dev_entity_id(string $role, string $username): string
|
||||
{
|
||||
return 'dev_ent_' . $role . '_' . substr(hash('sha256', $username), 0, 24);
|
||||
}
|
||||
|
||||
function qdb_dev_user_id(string $username): string
|
||||
{
|
||||
return 'dev_uid_' . substr(hash('sha256', 'user:' . $username), 0, 28);
|
||||
}
|
||||
|
||||
function qdb_dev_user_exists(PDO $pdo, string $username): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u');
|
||||
$stmt->execute([':u' => $username]);
|
||||
return (bool)$stmt->fetch();
|
||||
}
|
||||
|
||||
function qdb_dev_lookup_entity_id(PDO $pdo, string $username): string
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT entityID FROM users WHERE username = :u');
|
||||
$stmt->execute([':u' => $username]);
|
||||
$id = $stmt->fetchColumn();
|
||||
return $id !== false ? (string)$id : '';
|
||||
}
|
||||
|
||||
function qdb_dev_insert_user(
|
||||
PDO $pdo,
|
||||
string $userID,
|
||||
string $username,
|
||||
string $hash,
|
||||
string $role,
|
||||
string $entityID,
|
||||
int $now
|
||||
): void {
|
||||
$mcp = 0;
|
||||
$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' => $mcp,
|
||||
':now' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}>
|
||||
*/
|
||||
function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $variant): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT questionID, type, configJson, orderIndex
|
||||
FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
|
||||
);
|
||||
$stmt->execute([':qn' => $qnID]);
|
||||
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points, ao.orderIndex
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn
|
||||
ORDER BY ao.orderIndex'
|
||||
);
|
||||
$aoStmt->execute([':qn' => $qnID]);
|
||||
$optionsByQuestion = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionsByQuestion[$ao['questionID']][] = $ao;
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
$freeTextSamples = ['Testantwort', 'Dev-Eingabe', 'Beispieltext', 'N/A'];
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$type = $q['type'] ?? '';
|
||||
$localId = qdb_question_local_id($q['questionID'], $qnID);
|
||||
$config = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$v = $variant + (int)($q['orderIndex'] ?? 0);
|
||||
|
||||
switch ($type) {
|
||||
case 'last_page':
|
||||
break;
|
||||
|
||||
case 'glass_scale_question':
|
||||
$symData = [];
|
||||
foreach ($config['symptoms'] ?? [] as $si => $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
$symData[$sk] = QDB_DEV_GLASS_LABELS[($v + $si) % count(QDB_DEV_GLASS_LABELS)];
|
||||
}
|
||||
if ($symData !== []) {
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => json_encode($symData, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'radio_question':
|
||||
$opts = $optionsByQuestion[$q['questionID']] ?? [];
|
||||
if ($opts) {
|
||||
$pick = $opts[$v % count($opts)];
|
||||
$key = qdb_option_key($pick) ?: $pick['defaultText'];
|
||||
$answers[] = ['questionID' => $localId, 'answerOptionKey' => $key];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'multi_check_box_question':
|
||||
$opts = $optionsByQuestion[$q['questionID']] ?? [];
|
||||
if ($opts) {
|
||||
$pickCount = min(5, max(2, count($opts) > 20 ? 4 : 2), count($opts));
|
||||
$keys = [];
|
||||
for ($i = 0; $i < $pickCount; $i++) {
|
||||
$pick = $opts[($v + $i) % count($opts)];
|
||||
$key = qdb_option_key($pick) ?: $pick['defaultText'];
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
if ($keys !== []) {
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => qdb_encode_multi_check_values($keys),
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string_spinner':
|
||||
$opts = $config['options'] ?? [];
|
||||
if (is_array($opts) && $opts !== []) {
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => (string)$opts[$v % count($opts)],
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'value_spinner':
|
||||
case 'slider_question':
|
||||
$range = $config['range'] ?? ['min' => 0, 'max' => 10];
|
||||
$min = (int)($range['min'] ?? 0);
|
||||
$max = (int)($range['max'] ?? 10);
|
||||
if ($max < $min) {
|
||||
$max = $min;
|
||||
}
|
||||
$span = max(1, $max - $min + 1);
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'numericValue' => (float)($min + ($v % $span)),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'date_spinner':
|
||||
$precision = $config['precision'] ?? 'full';
|
||||
$dateRange = ($config['dateRange'] ?? 'past') === 'future' ? 'future' : 'past';
|
||||
$year = $dateRange === 'future'
|
||||
? ((int)date('Y') + ($v % 5))
|
||||
: (2018 + ($v % 6));
|
||||
$month = 1 + ($v % 12);
|
||||
$day = 1 + ($v % 28);
|
||||
$dateValue = match ($precision) {
|
||||
'year' => sprintf('%04d', $year),
|
||||
'year_month' => sprintf('%04d-%02d', $year, $month),
|
||||
default => sprintf('%04d-%02d-%02d', $year, $month, $day),
|
||||
};
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => $dateValue,
|
||||
];
|
||||
break;
|
||||
|
||||
case 'free_text':
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => $freeTextSamples[$v % count($freeTextSamples)],
|
||||
];
|
||||
break;
|
||||
|
||||
case 'client_coach_code_question':
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => 'DEV-IMPORT',
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}> $answers
|
||||
*/
|
||||
function qdb_dev_persist_submission(
|
||||
PDO $pdo,
|
||||
string $qnID,
|
||||
string $clientCode,
|
||||
string $assignedByCoach,
|
||||
array $answers,
|
||||
int $startedAt,
|
||||
int $completedAt
|
||||
): int {
|
||||
$qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn');
|
||||
$qRows->execute([':qn' => $qnID]);
|
||||
$shortIdMap = [];
|
||||
$typeByFullId = [];
|
||||
$freeTextMaxLen = [];
|
||||
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||
$fullId = $qRow['questionID'];
|
||||
$parts = explode('__', $fullId);
|
||||
$shortIdMap[end($parts)] = $fullId;
|
||||
$typeByFullId[$fullId] = $qRow['type'] ?? '';
|
||||
if (($qRow['type'] ?? '') === 'free_text') {
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
|
||||
}
|
||||
}
|
||||
|
||||
$aoRows = $pdo->prepare(
|
||||
'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn'
|
||||
);
|
||||
$aoRows->execute([':qn' => $qnID]);
|
||||
$optionMap = [];
|
||||
foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionMap[$ao['questionID']][$ao['defaultText']] = [
|
||||
'answerOptionID' => $ao['answerOptionID'],
|
||||
'points' => (int)$ao['points'],
|
||||
];
|
||||
}
|
||||
|
||||
$sumPoints = 0;
|
||||
$written = 0;
|
||||
$answeredAt = $completedAt;
|
||||
$answerInsert = $pdo->prepare(
|
||||
'INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) '
|
||||
. qdb_upsert_update(
|
||||
['clientCode', 'questionID'],
|
||||
[
|
||||
'answerOptionID' => 'excluded.answerOptionID',
|
||||
'freeTextValue' => 'excluded.freeTextValue',
|
||||
'numericValue' => 'excluded.numericValue',
|
||||
'answeredAt' => 'excluded.answeredAt',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($answers as $a) {
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullQID = $shortIdMap[$shortId] ?? null;
|
||||
if ($fullQID === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
|
||||
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
|
||||
if ($freeTextValue !== null && $freeTextValue !== '') {
|
||||
$maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
|
||||
$freeTextValue = sanitize_free_text($freeTextValue, $maxLen);
|
||||
if ($freeTextValue === '') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$answerOptionID = null;
|
||||
$qType = $typeByFullId[$fullQID] ?? '';
|
||||
|
||||
if ($qType === 'glass_scale_question' && $freeTextValue !== null && $freeTextValue !== '') {
|
||||
$decoded = json_decode($freeTextValue, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $label) {
|
||||
$label = trim((string)$label);
|
||||
if ($label !== '') {
|
||||
$sumPoints += QDB_DEV_GLASS_POINTS[$label] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($qType === 'multi_check_box_question' && $freeTextValue !== null && $freeTextValue !== '') {
|
||||
foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
|
||||
if (isset($optionMap[$fullQID][$mk])) {
|
||||
$sumPoints += $optionMap[$fullQID][$mk]['points'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$optionKey = $a['answerOptionKey'] ?? null;
|
||||
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
||||
$opt = $optionMap[$fullQID][(string)$optionKey];
|
||||
$answerOptionID = $opt['answerOptionID'];
|
||||
$sumPoints += $opt['points'];
|
||||
}
|
||||
}
|
||||
|
||||
$answerInsert->execute([
|
||||
':cc' => $clientCode,
|
||||
':qid' => $fullQID,
|
||||
':aoid' => $answerOptionID,
|
||||
':ftv' => $freeTextValue,
|
||||
':nv' => $numericValue,
|
||||
':at' => $answeredAt,
|
||||
]);
|
||||
$written++;
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
||||
VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) '
|
||||
. qdb_upsert_update(
|
||||
['clientCode', 'questionnaireID'],
|
||||
[
|
||||
'assignedByCoach' => 'excluded.assignedByCoach',
|
||||
'status' => "'completed'",
|
||||
'startedAt' => 'COALESCE(excluded.startedAt, startedAt)',
|
||||
'completedAt' => 'excluded.completedAt',
|
||||
'sumPoints' => 'excluded.sumPoints',
|
||||
]
|
||||
)
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':qn' => $qnID,
|
||||
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
||||
':sa' => $startedAt,
|
||||
':ca' => $completedAt,
|
||||
':sp' => $sumPoints,
|
||||
]);
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/** @param list<string> $ids */
|
||||
function qdb_dev_delete_by_ids(PDO $pdo, string $table, string $column, array $ids): int
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter($ids, static fn($id) => $id !== '' && $id !== null)));
|
||||
if ($ids === []) {
|
||||
return 0;
|
||||
}
|
||||
$total = 0;
|
||||
foreach (array_chunk($ids, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$stmt = $pdo->prepare("DELETE FROM $table WHERE $column IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$total += $stmt->rowCount();
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dev-prefixed test users, clients, and their answers (does not touch other accounts).
|
||||
*
|
||||
* @return array{deleted: array<string, int>}
|
||||
*/
|
||||
function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
|
||||
{
|
||||
$usernamePrefix = trim((string)($options['usernamePrefix'] ?? 'dev_'));
|
||||
$clientPrefix = trim((string)($options['clientCodePrefix'] ?? 'DEV-CL-'));
|
||||
if ($usernamePrefix === '' || stripos($usernamePrefix, 'dev') !== 0) {
|
||||
json_error('INVALID_FIELD', 'usernamePrefix must start with "dev"', 400);
|
||||
}
|
||||
if ($clientPrefix === '' || stripos($clientPrefix, 'DEV') !== 0) {
|
||||
json_error('INVALID_FIELD', 'clientCodePrefix must start with "DEV"', 400);
|
||||
}
|
||||
|
||||
$userLike = $usernamePrefix . '%';
|
||||
$clientLike = $clientPrefix . '%';
|
||||
|
||||
$deleted = [
|
||||
'submissions' => 0,
|
||||
'followup_notes' => 0,
|
||||
'client_answers' => 0,
|
||||
'completed_questionnaires' => 0,
|
||||
'clients' => 0,
|
||||
'sessions' => 0,
|
||||
'users' => 0,
|
||||
'coaches' => 0,
|
||||
'supervisors' => 0,
|
||||
'admins' => 0,
|
||||
];
|
||||
|
||||
$coachIds = [];
|
||||
$stmt = $pdo->prepare('SELECT coachID FROM coach WHERE username LIKE :pfx');
|
||||
$stmt->execute([':pfx' => $userLike]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
$coachIds[$id] = true;
|
||||
}
|
||||
|
||||
$supervisorIds = [];
|
||||
$stmt = $pdo->prepare('SELECT supervisorID FROM supervisor WHERE username LIKE :pfx');
|
||||
$stmt->execute([':pfx' => $userLike]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
$supervisorIds[$id] = true;
|
||||
}
|
||||
|
||||
$adminIds = [];
|
||||
$stmt = $pdo->prepare('SELECT adminID FROM admin WHERE username LIKE :pfx');
|
||||
$stmt->execute([':pfx' => $userLike]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
$adminIds[$id] = true;
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
$userRows = $pdo->prepare('SELECT userID, role, entityID FROM users WHERE username LIKE :pfx');
|
||||
$userRows->execute([':pfx' => $userLike]);
|
||||
foreach ($userRows->fetchAll(PDO::FETCH_ASSOC) as $u) {
|
||||
$userIds[$u['userID']] = true;
|
||||
switch ($u['role']) {
|
||||
case 'coach':
|
||||
$coachIds[$u['entityID']] = true;
|
||||
break;
|
||||
case 'supervisor':
|
||||
$supervisorIds[$u['entityID']] = true;
|
||||
break;
|
||||
case 'admin':
|
||||
$adminIds[$u['entityID']] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$coachIdList = array_keys($coachIds);
|
||||
$clientCodes = [];
|
||||
$ccStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx');
|
||||
$ccStmt->execute([':pfx' => $clientLike]);
|
||||
foreach ($ccStmt->fetchAll(PDO::FETCH_COLUMN) as $code) {
|
||||
$clientCodes[$code] = true;
|
||||
}
|
||||
if ($coachIdList !== []) {
|
||||
foreach (array_chunk($coachIdList, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$byCoach = $pdo->prepare("SELECT clientCode FROM client WHERE coachID IN ($ph)");
|
||||
$byCoach->execute($chunk);
|
||||
foreach ($byCoach->fetchAll(PDO::FETCH_COLUMN) as $code) {
|
||||
$clientCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$clientCodeList = array_keys($clientCodes);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($clientCodeList !== []) {
|
||||
$responseDeleted = qdb_delete_client_response_data($pdo, $clientCodeList);
|
||||
$deleted['submissions'] += $responseDeleted['submissions'];
|
||||
$deleted['followup_notes'] += $responseDeleted['followup_notes'];
|
||||
$deleted['client_answers'] += $responseDeleted['client_answers'];
|
||||
$deleted['completed_questionnaires'] += $responseDeleted['completed_questionnaires'];
|
||||
}
|
||||
if ($coachIdList !== []) {
|
||||
$deleted['submissions'] += qdb_delete_submissions_for_coaches($pdo, $coachIdList);
|
||||
$deleted['completed_questionnaires'] += qdb_dev_delete_by_ids(
|
||||
$pdo,
|
||||
'completed_questionnaire',
|
||||
'assignedByCoach',
|
||||
$coachIdList
|
||||
);
|
||||
}
|
||||
|
||||
if ($clientCodeList !== []) {
|
||||
$deleted['clients'] += qdb_dev_delete_by_ids($pdo, 'client', 'clientCode', $clientCodeList);
|
||||
}
|
||||
if ($coachIdList !== []) {
|
||||
foreach (array_chunk($coachIdList, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$delCl = $pdo->prepare("DELETE FROM client WHERE coachID IN ($ph)");
|
||||
$delCl->execute($chunk);
|
||||
$deleted['clients'] += $delCl->rowCount();
|
||||
}
|
||||
}
|
||||
$delClPrefix = $pdo->prepare('DELETE FROM client WHERE clientCode LIKE :pfx');
|
||||
$delClPrefix->execute([':pfx' => $clientLike]);
|
||||
$deleted['clients'] += $delClPrefix->rowCount();
|
||||
|
||||
$deleted['sessions'] = qdb_dev_delete_by_ids($pdo, 'session', 'userID', array_keys($userIds));
|
||||
|
||||
$deleted['users'] = qdb_dev_delete_by_ids($pdo, 'users', 'userID', array_keys($userIds));
|
||||
$delUsersLike = $pdo->prepare('DELETE FROM users WHERE username LIKE :pfx');
|
||||
$delUsersLike->execute([':pfx' => $userLike]);
|
||||
$deleted['users'] += $delUsersLike->rowCount();
|
||||
|
||||
$deleted['coaches'] = qdb_dev_delete_by_ids($pdo, 'coach', 'coachID', $coachIdList);
|
||||
$delCoachLike = $pdo->prepare('DELETE FROM coach WHERE username LIKE :pfx');
|
||||
$delCoachLike->execute([':pfx' => $userLike]);
|
||||
$deleted['coaches'] += $delCoachLike->rowCount();
|
||||
|
||||
$deleted['supervisors'] = qdb_dev_delete_by_ids($pdo, 'supervisor', 'supervisorID', array_keys($supervisorIds));
|
||||
$delSvLike = $pdo->prepare('DELETE FROM supervisor WHERE username LIKE :pfx');
|
||||
$delSvLike->execute([':pfx' => $userLike]);
|
||||
$deleted['supervisors'] += $delSvLike->rowCount();
|
||||
|
||||
$deleted['admins'] = qdb_dev_delete_by_ids($pdo, 'admin', 'adminID', array_keys($adminIds));
|
||||
$delAdLike = $pdo->prepare('DELETE FROM admin WHERE username LIKE :pfx');
|
||||
$delAdLike->execute([':pfx' => $userLike]);
|
||||
$deleted['admins'] += $delAdLike->rowCount();
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['deleted' => $deleted];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all clients, completions, coaches, and supervisors. Keeps admin accounts
|
||||
* and questionnaire definitions (questions, translations, etc.).
|
||||
*
|
||||
* @return array{deleted: array<string, int>, kept: array<string, int>}
|
||||
*/
|
||||
function qdb_wipe_all_data_except_admins(PDO $pdo): array
|
||||
{
|
||||
$deleted = [
|
||||
'client_answers' => 0,
|
||||
'completed_questionnaires' => 0,
|
||||
'clients' => 0,
|
||||
'sessions' => 0,
|
||||
'users' => 0,
|
||||
'coaches' => 0,
|
||||
'supervisors' => 0,
|
||||
];
|
||||
|
||||
$keptStmt = $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'admin'");
|
||||
$keptAdmins = (int)$keptStmt->fetchColumn();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Interview data references clients/coaches; coach references supervisor.
|
||||
// Disable FK checks for this bulk wipe (same pattern as questionnaire delete).
|
||||
qdb_set_foreign_keys($pdo, false);
|
||||
|
||||
$deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
|
||||
if (qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||
$pdo->exec('DELETE FROM client_answer_submission');
|
||||
}
|
||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$pdo->exec('DELETE FROM questionnaire_submission');
|
||||
}
|
||||
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||
$pdo->exec('DELETE FROM client_followup_note');
|
||||
}
|
||||
$deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire');
|
||||
$deleted['clients'] = (int)$pdo->exec('DELETE FROM client');
|
||||
|
||||
$deleted['coaches'] = (int)$pdo->exec('DELETE FROM coach');
|
||||
$deleted['supervisors'] = (int)$pdo->exec('DELETE FROM supervisor');
|
||||
|
||||
$sess = $pdo->prepare(
|
||||
"DELETE FROM session WHERE userID IN (SELECT userID FROM users WHERE role != 'admin')"
|
||||
);
|
||||
$sess->execute();
|
||||
$deleted['sessions'] = $sess->rowCount();
|
||||
|
||||
$users = $pdo->prepare("DELETE FROM users WHERE role != 'admin'");
|
||||
$users->execute();
|
||||
$deleted['users'] = $users->rowCount();
|
||||
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
try {
|
||||
qdb_set_foreign_keys($pdo, true);
|
||||
} catch (Throwable $ignored) {
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'deleted' => $deleted,
|
||||
'kept' => ['admins' => $keptAdmins],
|
||||
];
|
||||
}
|
||||
60
lib/encrypted_payload.php
Normal file
60
lib/encrypted_payload.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
* Application-layer encryption for sensitive mobile API payloads.
|
||||
* AES-256-GCM (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
|
||||
* Legacy CBC envelopes are accepted for compatibility with older app builds.
|
||||
*/
|
||||
|
||||
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
|
||||
$key = hkdf_session_key_from_token($tokenHex);
|
||||
return [
|
||||
'encrypted' => true,
|
||||
'alg' => 'A256GCM',
|
||||
'payload' => base64_encode(qdb_aes256_gcm_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);
|
||||
$alg = isset($envelope['alg']) && is_string($envelope['alg']) ? $envelope['alg'] : 'A256CBC';
|
||||
return match ($alg) {
|
||||
'A256GCM' => qdb_aes256_gcm_decrypt_bytes($raw, $key),
|
||||
'A256CBC' => aes256_cbc_decrypt_bytes($raw, $key),
|
||||
default => throw new InvalidArgumentException('Unsupported encrypted envelope algorithm'),
|
||||
};
|
||||
}
|
||||
|
||||
function qdb_aes256_gcm_encrypt_bytes(string $plain, string $key): string {
|
||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
|
||||
if ($cipher === false || strlen($tag) !== 16) {
|
||||
throw new RuntimeException('openssl_encrypt failed');
|
||||
}
|
||||
return $iv . $cipher . $tag;
|
||||
}
|
||||
|
||||
function qdb_aes256_gcm_decrypt_bytes(string $data, string $key): string {
|
||||
$ivLen = 12;
|
||||
$tagLen = 16;
|
||||
if (strlen($data) <= $ivLen + $tagLen) {
|
||||
throw new InvalidArgumentException('cipher too short');
|
||||
}
|
||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||
$iv = substr($data, 0, $ivLen);
|
||||
$tag = substr($data, -$tagLen);
|
||||
$ct = substr($data, $ivLen, -$tagLen);
|
||||
$plain = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
|
||||
if ($plain === false) {
|
||||
throw new InvalidArgumentException('openssl_decrypt failed');
|
||||
}
|
||||
return $plain;
|
||||
}
|
||||
89
lib/errors.php
Normal file
89
lib/errors.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared API error helpers for handlers and db_init.
|
||||
*/
|
||||
|
||||
function qdb_is_lock_exception(Throwable $e): bool
|
||||
{
|
||||
$msg = $e->getMessage();
|
||||
return str_contains($msg, 'Could not acquire lock')
|
||||
|| str_contains($msg, 'Could not open lock file');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map internal exceptions to safe, identifiable API error messages (details go to error_log).
|
||||
*/
|
||||
function qdb_public_error_message(Throwable $e, string $contextLabel = 'Operation'): string
|
||||
{
|
||||
$msg = $e->getMessage();
|
||||
|
||||
if (qdb_is_lock_exception($e)) {
|
||||
return 'Database is busy because another update is in progress. Please try again in a moment.';
|
||||
}
|
||||
if (str_contains($msg, 'decrypt') || str_contains($msg, 'QDB_MASTER_KEY')) {
|
||||
return 'Database configuration error. Contact the server administrator.';
|
||||
}
|
||||
if (str_contains($msg, 'Could not read') || str_contains($msg, 'Could not write')
|
||||
|| str_contains($msg, 'Could not save')) {
|
||||
return 'Database storage error. Contact the server administrator.';
|
||||
}
|
||||
if ($e instanceof PDOException) {
|
||||
return $contextLabel . ' failed due to a database error.';
|
||||
}
|
||||
|
||||
return $contextLabel . ' failed. If this persists, contact support.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string, 2: int} code, message, http status
|
||||
*/
|
||||
function qdb_api_error_for_exception(Throwable $e, string $contextLabel = 'Operation'): array
|
||||
{
|
||||
if (qdb_is_lock_exception($e)) {
|
||||
return ['LOCKED', qdb_public_error_message($e, $contextLabel), 503];
|
||||
}
|
||||
return ['SERVER_ERROR', qdb_public_error_message($e, $contextLabel), 500];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard handler catch: rollback, discard DB temp file, log, JSON error (never returns).
|
||||
*/
|
||||
function qdb_handler_fail(
|
||||
Throwable $e,
|
||||
string $contextLabel,
|
||||
?PDO $pdo = null,
|
||||
?string $tmpDb = null,
|
||||
$lockFp = null
|
||||
): never {
|
||||
if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) {
|
||||
throw $e;
|
||||
}
|
||||
if ($pdo !== null && $pdo->inTransaction()) {
|
||||
try {
|
||||
$pdo->rollBack();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
if ($tmpDb !== null && $lockFp !== null) {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
qdb_emit_api_error($e, $contextLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log exception and exit with unified JSON error (never returns).
|
||||
*/
|
||||
function qdb_emit_api_error(Throwable $e, string $contextLabel): never
|
||||
{
|
||||
if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) {
|
||||
throw $e;
|
||||
}
|
||||
if (function_exists('qdb_api_log_note_exception')) {
|
||||
qdb_api_log_note_exception($e);
|
||||
}
|
||||
error_log($contextLabel . ': ' . $e->getMessage());
|
||||
[$code, $message, $status] = qdb_api_error_for_exception($e, $contextLabel);
|
||||
json_error($code, $message, $status);
|
||||
}
|
||||
221
lib/keycloak_auth.php
Normal file
221
lib/keycloak_auth.php
Normal file
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Minimal OpenID Connect support for Keycloak-backed website login.
|
||||
*
|
||||
* Required .env values:
|
||||
* - QDB_KEYCLOAK_ISSUER, e.g. https://.../realms/...
|
||||
* - QDB_KEYCLOAK_CLIENT_ID
|
||||
* - QDB_KEYCLOAK_CLIENT_SECRET
|
||||
*
|
||||
* Optional:
|
||||
* - QDB_KEYCLOAK_REDIRECT_URI (defaults to current /api/auth/keycloak-callback)
|
||||
* - QDB_KEYCLOAK_USERNAME_CLAIM (defaults to preferred_username)
|
||||
* - QDB_KEYCLOAK_DISPLAY_NAME (defaults to University Konstanz)
|
||||
*/
|
||||
|
||||
function qdb_keycloak_config(): array
|
||||
{
|
||||
$issuer = rtrim((string)(qdb_env_get('QDB_KEYCLOAK_ISSUER') ?? ''), '/');
|
||||
$clientId = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_ID') ?? '');
|
||||
$clientSecret = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET') ?? '');
|
||||
return [
|
||||
'issuer' => $issuer,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'redirect_uri' => (string)(qdb_env_get('QDB_KEYCLOAK_REDIRECT_URI') ?? qdb_keycloak_default_redirect_uri()),
|
||||
'username_claim' => (string)(qdb_env_get('QDB_KEYCLOAK_USERNAME_CLAIM') ?? 'preferred_username'),
|
||||
'display_name' => (string)(qdb_env_get('QDB_KEYCLOAK_DISPLAY_NAME') ?? 'University Konstanz'),
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_keycloak_is_configured(?array $config = null): bool
|
||||
{
|
||||
$config ??= qdb_keycloak_config();
|
||||
return $config['issuer'] !== '' && $config['client_id'] !== '' && $config['client_secret'] !== '';
|
||||
}
|
||||
|
||||
function qdb_keycloak_default_redirect_uri(): string
|
||||
{
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
|
||||
$base = rtrim(str_replace('\\', '/', dirname($script)), '/');
|
||||
return $scheme . '://' . $host . $base . '/auth/keycloak-callback';
|
||||
}
|
||||
|
||||
function qdb_keycloak_frontend_url(): string
|
||||
{
|
||||
$configured = (string)(qdb_env_get('QDB_KEYCLOAK_FRONTEND_URL') ?? '');
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
|
||||
$apiBase = rtrim(str_replace('\\', '/', dirname($script)), '/');
|
||||
$root = preg_replace('#/api$#', '', $apiBase) ?: '';
|
||||
return $scheme . '://' . $host . $root . '/website/index.html';
|
||||
}
|
||||
|
||||
function qdb_keycloak_state_signing_key(): string
|
||||
{
|
||||
$secret = qdb_env_get('QDB_KEYCLOAK_STATE_SECRET')
|
||||
?? qdb_env_get('QDB_MASTER_KEY')
|
||||
?? qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET')
|
||||
?? '';
|
||||
if ($secret === '') {
|
||||
throw new RuntimeException('No signing secret available for Keycloak state');
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
function qdb_keycloak_base64url(string $bytes): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function qdb_keycloak_unbase64url(string $value): string
|
||||
{
|
||||
$padded = strtr($value, '-_', '+/');
|
||||
$padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
|
||||
$decoded = base64_decode($padded, true);
|
||||
if ($decoded === false) {
|
||||
throw new InvalidArgumentException('Invalid base64url value');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function qdb_keycloak_create_state(string $nonce): string
|
||||
{
|
||||
$payload = json_encode([
|
||||
'nonce' => $nonce,
|
||||
'exp' => time() + 600,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$body = qdb_keycloak_base64url($payload);
|
||||
$sig = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
|
||||
return $body . '.' . $sig;
|
||||
}
|
||||
|
||||
function qdb_keycloak_verify_state(string $state): array
|
||||
{
|
||||
$parts = explode('.', $state, 2);
|
||||
if (count($parts) !== 2) {
|
||||
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
|
||||
}
|
||||
[$body, $sig] = $parts;
|
||||
$expected = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
|
||||
}
|
||||
$payload = json_decode(qdb_keycloak_unbase64url($body), true);
|
||||
if (!is_array($payload) || (int)($payload['exp'] ?? 0) < time()) {
|
||||
json_error('INVALID_STATE', 'Expired Keycloak state', 400);
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function qdb_keycloak_discovery(array $config): array
|
||||
{
|
||||
$url = $config['issuer'] . '/.well-known/openid-configuration';
|
||||
$json = qdb_keycloak_http_json($url);
|
||||
foreach (['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'] as $key) {
|
||||
if (empty($json[$key])) {
|
||||
throw new RuntimeException("Keycloak discovery missing $key");
|
||||
}
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
function qdb_keycloak_http_json(string $url, array $options = []): array
|
||||
{
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => $options['method'] ?? 'GET',
|
||||
'header' => $options['headers'] ?? '',
|
||||
'content' => $options['content'] ?? '',
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$raw = file_get_contents($url, false, $context);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Keycloak request failed');
|
||||
}
|
||||
$status = 200;
|
||||
foreach (($http_response_header ?? []) as $header) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) {
|
||||
$status = (int)$m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
if ($status < 200 || $status >= 300 || !is_array($json)) {
|
||||
throw new RuntimeException('Keycloak returned an invalid response');
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
function qdb_keycloak_exchange_code(array $config, array $discovery, string $code): array
|
||||
{
|
||||
$body = http_build_query([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $config['redirect_uri'],
|
||||
'client_id' => $config['client_id'],
|
||||
'client_secret' => $config['client_secret'],
|
||||
]);
|
||||
return qdb_keycloak_http_json($discovery['token_endpoint'], [
|
||||
'method' => 'POST',
|
||||
'headers' => "Content-Type: application/x-www-form-urlencoded\r\n",
|
||||
'content' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_keycloak_userinfo(array $discovery, string $accessToken): array
|
||||
{
|
||||
return qdb_keycloak_http_json($discovery['userinfo_endpoint'], [
|
||||
'headers' => "Authorization: Bearer $accessToken\r\n",
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_keycloak_claim_username(array $config, array $claims): string
|
||||
{
|
||||
$claim = $config['username_claim'] ?: 'preferred_username';
|
||||
$username = trim((string)($claims[$claim] ?? ''));
|
||||
if ($username === '' && $claim !== 'email') {
|
||||
$username = trim((string)($claims['email'] ?? ''));
|
||||
}
|
||||
if ($username === '') {
|
||||
json_error('KEYCLOAK_NO_USERNAME', 'University login did not provide a usable username', 403);
|
||||
}
|
||||
return $username;
|
||||
}
|
||||
|
||||
function qdb_keycloak_redirect(string $url, int $status = 302): never
|
||||
{
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\RawHttpResponse('', ['Location' => $url], $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Location: ' . $url, true, $status);
|
||||
exit;
|
||||
}
|
||||
|
||||
function qdb_keycloak_finish_html(string $token, string $user, string $role): never
|
||||
{
|
||||
$target = qdb_keycloak_frontend_url() . '#/';
|
||||
$payload = json_encode([
|
||||
'token' => $token,
|
||||
'user' => $user,
|
||||
'role' => $role,
|
||||
'target' => $target,
|
||||
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR);
|
||||
qdb_http_download(
|
||||
"<!doctype html><meta charset=\"utf-8\"><script>const d=$payload;localStorage.setItem('qdb_token',d.token);localStorage.setItem('qdb_user',d.user);localStorage.setItem('qdb_role',d.role);location.replace(d.target);</script>",
|
||||
['Content-Type' => 'text/html; charset=UTF-8']
|
||||
);
|
||||
}
|
||||
|
||||
176
lib/login_rate_limit.php
Normal file
176
lib/login_rate_limit.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
function qdb_login_rate_limit_path(): string
|
||||
{
|
||||
return dirname(QDB_PATH) . '/.login_rate_limit.json';
|
||||
}
|
||||
|
||||
function qdb_client_ip(): string
|
||||
{
|
||||
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if ($raw === '') {
|
||||
return '0.0.0.0';
|
||||
}
|
||||
if (str_contains($raw, ',')) {
|
||||
$raw = trim(explode(',', $raw)[0]);
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_key(string $username): string
|
||||
{
|
||||
$ip = qdb_client_ip();
|
||||
return hash('sha256', strtolower(trim($username)) . "\0" . $ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: bool, 1: int} allowed, retry_after_seconds
|
||||
*/
|
||||
function qdb_login_rate_limit_check(string $username, ?array $settings = null): array
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
$max = (int)$settings['login_max_attempts'];
|
||||
$window = (int)$settings['login_window_seconds'];
|
||||
$lockout = (int)$settings['login_lockout_seconds'];
|
||||
$now = time();
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return [true, 0];
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return [true, 0];
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$lockedUntil = (int)($entry['locked_until'] ?? 0);
|
||||
if ($lockedUntil > $now) {
|
||||
return [false, $lockedUntil - $now];
|
||||
}
|
||||
$attempts = array_values(array_filter(
|
||||
(array)($entry['attempts'] ?? []),
|
||||
static fn($t) => is_int($t) && $t > $now - $window
|
||||
));
|
||||
if (count($attempts) >= $max) {
|
||||
$entry['locked_until'] = $now + $lockout;
|
||||
$entry['attempts'] = $attempts;
|
||||
$data[$key] = $entry;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
return [false, $lockout];
|
||||
}
|
||||
return [true, 0];
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
$max = (int)$settings['login_max_attempts'];
|
||||
$window = (int)$settings['login_window_seconds'];
|
||||
$lockout = (int)$settings['login_lockout_seconds'];
|
||||
$now = time();
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return 0;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$attempts = array_values(array_filter(
|
||||
(array)($entry['attempts'] ?? []),
|
||||
static fn($t) => is_int($t) && $t > $now - $window
|
||||
));
|
||||
$attempts[] = $now;
|
||||
$retry = 0;
|
||||
if (count($attempts) >= $max) {
|
||||
$entry['locked_until'] = $now + $lockout;
|
||||
$retry = $lockout;
|
||||
}
|
||||
$entry['attempts'] = $attempts;
|
||||
$data[$key] = $entry;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
return $retry;
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_clear(string $username): void
|
||||
{
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
if (!is_file($path)) {
|
||||
return;
|
||||
}
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data) || !isset($data[$key])) {
|
||||
return;
|
||||
}
|
||||
unset($data[$key]);
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_deny(int $retryAfterSeconds): never
|
||||
{
|
||||
if ($retryAfterSeconds > 0) {
|
||||
header('Retry-After: ' . (string)$retryAfterSeconds);
|
||||
}
|
||||
json_error(
|
||||
'RATE_LIMITED',
|
||||
'Too many login attempts. Please wait before trying again.',
|
||||
429
|
||||
);
|
||||
}
|
||||
412
lib/questionnaire_structure.php
Normal file
412
lib/questionnaire_structure.php
Normal file
@ -0,0 +1,412 @@
|
||||
<?php
|
||||
/**
|
||||
* Questionnaire structure revisions: bump, snapshot manifests, retire questions.
|
||||
*/
|
||||
|
||||
function qdb_active_questions_clause(string $alias = 'question'): string {
|
||||
return "COALESCE($alias.retiredAt, 0) = 0";
|
||||
}
|
||||
|
||||
function qdb_active_options_clause(string $alias = 'answer_option'): string {
|
||||
return "COALESCE($alias.retiredAt, 0) = 0";
|
||||
}
|
||||
|
||||
function qdb_questionnaire_structure_revision(PDO $pdo, string $qnID): int {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(structureRevision, 1) FROM questionnaire WHERE questionnaireID = :id'
|
||||
);
|
||||
$stmt->execute([':id' => $qnID]);
|
||||
$rev = $stmt->fetchColumn();
|
||||
return $rev !== false ? max(1, (int)$rev) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_build_structure_manifest(PDO $pdo, string $qnID, bool $includeRetired = false): array {
|
||||
$where = 'questionnaireID = :qn';
|
||||
if (!$includeRetired) {
|
||||
$where .= ' AND ' . qdb_active_questions_clause('question');
|
||||
}
|
||||
$qStmt = $pdo->prepare(
|
||||
"SELECT questionID, type, isRequired, configJson, defaultText, retiredAt
|
||||
FROM question WHERE $where ORDER BY orderIndex"
|
||||
);
|
||||
$qStmt->execute([':qn' => $qnID]);
|
||||
$questions = [];
|
||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||
$fullId = $qRow['questionID'];
|
||||
$shortId = qdb_question_local_id($fullId, $qnID);
|
||||
$config = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$qKey = qdb_question_key($config, $qRow['defaultText']);
|
||||
|
||||
$optWhere = 'questionID = :qid';
|
||||
if (!$includeRetired) {
|
||||
$optWhere .= ' AND ' . qdb_active_options_clause('answer_option');
|
||||
}
|
||||
$aoStmt = $pdo->prepare(
|
||||
"SELECT defaultText, points FROM answer_option WHERE $optWhere ORDER BY orderIndex"
|
||||
);
|
||||
$aoStmt->execute([':qid' => $fullId]);
|
||||
$options = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$options[] = [
|
||||
'key' => $ao['defaultText'],
|
||||
'points' => (int)$ao['points'],
|
||||
];
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'questionID' => $fullId,
|
||||
'shortId' => $shortId,
|
||||
'questionKey'=> $qKey,
|
||||
'type' => $qRow['type'] ?? '',
|
||||
'isRequired' => (int)($qRow['isRequired'] ?? 0) === 1,
|
||||
'options' => $options,
|
||||
];
|
||||
if ($includeRetired && (int)($qRow['retiredAt'] ?? 0) > 0) {
|
||||
$entry['retired'] = true;
|
||||
}
|
||||
if (($qRow['type'] ?? '') === 'glass_scale_question' && !empty($config['symptoms'])) {
|
||||
$entry['symptoms'] = array_values(array_map('strval', (array)$config['symptoms']));
|
||||
}
|
||||
$questions[] = $entry;
|
||||
}
|
||||
|
||||
return [
|
||||
'questionnaireID' => $qnID,
|
||||
'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID),
|
||||
'questions' => $questions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
function qdb_load_structure_manifest(PDO $pdo, string $qnID, int $revision): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT manifestJson FROM questionnaire_structure_snapshot
|
||||
WHERE questionnaireID = :qn AND structureRevision = :rev'
|
||||
);
|
||||
$stmt->execute([':qn' => $qnID, ':rev' => $revision]);
|
||||
$json = $stmt->fetchColumn();
|
||||
if ($json === false || $json === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode((string)$json, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
function qdb_save_structure_snapshot(PDO $pdo, string $qnID, int $revision, array $manifest): void {
|
||||
$json = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
$json = '{}';
|
||||
}
|
||||
$pdo->prepare(
|
||||
'INSERT INTO questionnaire_structure_snapshot (questionnaireID, structureRevision, createdAt, manifestJson)
|
||||
VALUES (:qn, :rev, :ts, :mj) '
|
||||
. qdb_upsert_update(
|
||||
['questionnaireID', 'structureRevision'],
|
||||
[
|
||||
'manifestJson' => 'excluded.manifestJson',
|
||||
'createdAt' => 'excluded.createdAt',
|
||||
]
|
||||
)
|
||||
)->execute([
|
||||
':qn' => $qnID,
|
||||
':rev' => $revision,
|
||||
':ts' => time(),
|
||||
':mj' => $json,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill revision-1 snapshots for questionnaires missing any snapshot row.
|
||||
*/
|
||||
function qdb_backfill_structure_snapshots(PDO $pdo): bool {
|
||||
$ids = $pdo->query('SELECT questionnaireID FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$changed = false;
|
||||
foreach ($ids as $qnID) {
|
||||
$qnID = (string)$qnID;
|
||||
$chk = $pdo->prepare(
|
||||
'SELECT 1 FROM questionnaire_structure_snapshot
|
||||
WHERE questionnaireID = :qn LIMIT 1'
|
||||
);
|
||||
$chk->execute([':qn' => $qnID]);
|
||||
if ($chk->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$rev = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
$manifest = qdb_build_structure_manifest($pdo, $qnID, true);
|
||||
$manifest['structureRevision'] = $rev;
|
||||
qdb_save_structure_snapshot($pdo, $qnID, $rev, $manifest);
|
||||
$changed = true;
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
function qdb_bump_structure_revision(PDO $pdo, string $qnID, string $reason = ''): int {
|
||||
$current = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
$manifest = qdb_build_structure_manifest($pdo, $qnID, false);
|
||||
$manifest['structureRevision'] = $current;
|
||||
qdb_save_structure_snapshot($pdo, $qnID, $current, $manifest);
|
||||
|
||||
$newRev = $current + 1;
|
||||
$pdo->prepare(
|
||||
'UPDATE questionnaire SET structureRevision = :rev, structureChangedAt = :ts WHERE questionnaireID = :qn'
|
||||
)->execute([':rev' => $newRev, ':ts' => time(), ':qn' => $qnID]);
|
||||
|
||||
return $newRev;
|
||||
}
|
||||
|
||||
function qdb_questionnaire_id_for_question(PDO $pdo, string $questionID): ?string {
|
||||
$stmt = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :qid');
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
$id = $stmt->fetchColumn();
|
||||
return $id !== false ? (string)$id : null;
|
||||
}
|
||||
|
||||
function qdb_option_has_client_data(PDO $pdo, string $answerOptionID): bool {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM client_answer WHERE answerOptionID = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $answerOptionID]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
return true;
|
||||
}
|
||||
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM client_answer_submission WHERE answerOptionID = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':id' => $answerOptionID]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_question_has_client_data(PDO $pdo, string $questionID): bool {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM client_answer WHERE questionID = :qid LIMIT 1');
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
return true;
|
||||
}
|
||||
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM client_answer_submission WHERE questionID = :qid LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_retire_question(PDO $pdo, string $questionID, int $revision): void {
|
||||
$pdo->prepare(
|
||||
'UPDATE question SET retiredAt = :ts, retiredInRevision = :rev
|
||||
WHERE questionID = :qid AND COALESCE(retiredAt, 0) = 0'
|
||||
)->execute([':ts' => time(), ':rev' => $revision, ':qid' => $questionID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build submit maps from a structure manifest (current or legacy).
|
||||
*
|
||||
* @return array{
|
||||
* shortIdMap: array<string, string>,
|
||||
* shortIdToType: array<string, string>,
|
||||
* optionMap: array<string, array<string, array{answerOptionID: string, points: int}>>,
|
||||
* symptomParentMap: array<string, string>,
|
||||
* freeTextMaxLen: array<string, int>,
|
||||
* manifestQuestionIds: list<string>
|
||||
* }
|
||||
*/
|
||||
function qdb_submit_maps_from_manifest(PDO $pdo, string $qnID, array $manifest): array {
|
||||
$shortIdMap = [];
|
||||
$shortIdToType = [];
|
||||
$optionMap = [];
|
||||
$symptomParentMap = [];
|
||||
$freeTextMaxLen = [];
|
||||
$manifestQuestionIds = [];
|
||||
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||
if ($fullId === '' || $shortId === '') {
|
||||
continue;
|
||||
}
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
$shortIdMap[$shortId] = $fullId;
|
||||
$shortIdToType[$shortId] = $type;
|
||||
$manifestQuestionIds[] = $fullId;
|
||||
|
||||
if ($type === 'free_text') {
|
||||
$cfgStmt = $pdo->prepare('SELECT configJson FROM question WHERE questionID = :qid');
|
||||
$cfgStmt->execute([':qid' => $fullId]);
|
||||
$cfgJson = $cfgStmt->fetchColumn();
|
||||
$cfg = json_decode($cfgJson ?: '{}', true) ?: [];
|
||||
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
|
||||
}
|
||||
|
||||
$optionMap[$fullId] = [];
|
||||
foreach ($qEntry['options'] ?? [] as $opt) {
|
||||
if (!is_array($opt)) {
|
||||
continue;
|
||||
}
|
||||
$key = (string)($opt['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT answerOptionID, points FROM answer_option
|
||||
WHERE questionID = :qid AND defaultText = :k LIMIT 1'
|
||||
);
|
||||
$aoStmt->execute([':qid' => $fullId, ':k' => $key]);
|
||||
$aoRow = $aoStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$optionMap[$fullId][$key] = [
|
||||
'answerOptionID' => $aoRow ? (string)$aoRow['answerOptionID'] : '',
|
||||
'points' => $aoRow ? (int)$aoRow['points'] : (int)($opt['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'glass_scale_question') {
|
||||
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk !== '') {
|
||||
$symptomParentMap[$sk] = $fullId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($symptomParentMap === []) {
|
||||
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
|
||||
}
|
||||
|
||||
return [
|
||||
'shortIdMap' => $shortIdMap,
|
||||
'shortIdToType' => $shortIdToType,
|
||||
'optionMap' => $optionMap,
|
||||
'symptomParentMap' => $symptomParentMap,
|
||||
'freeTextMaxLen' => $freeTextMaxLen,
|
||||
'manifestQuestionIds' => $manifestQuestionIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build submit maps from active DB questions (current revision).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_submit_maps_from_active_questions(PDO $pdo, string $qnID): array {
|
||||
$manifest = qdb_build_structure_manifest($pdo, $qnID, false);
|
||||
$manifest['structureRevision'] = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
return qdb_submit_maps_from_manifest($pdo, $qnID, $manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{field: string, header: string, questionID: string, type: string}>
|
||||
*/
|
||||
function qdb_result_columns_from_manifest(array $manifest): array {
|
||||
$cols = [];
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||
$qKey = (string)($qEntry['questionKey'] ?? '');
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
if ($fullId === '') {
|
||||
continue;
|
||||
}
|
||||
$header = $qKey !== '' ? $qKey : ($shortId !== '' ? $shortId : $fullId);
|
||||
$cols[] = [
|
||||
'field' => $header,
|
||||
'header' => $header,
|
||||
'questionID' => $fullId,
|
||||
'type' => $type,
|
||||
];
|
||||
if ($type === 'glass_scale_question') {
|
||||
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
$cols[] = [
|
||||
'field' => $sk,
|
||||
'header' => $sk,
|
||||
'questionID' => $fullId,
|
||||
'type' => 'glass_symptom',
|
||||
'symptomKey' => $sk,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
|
||||
function qdb_compute_questionnaire_score_from_manifest(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
array $manifest
|
||||
): int {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
$qnID = (string)($manifest['questionnaireID'] ?? '');
|
||||
if ($qnID === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$questionIDs = [];
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (is_array($qEntry) && !empty($qEntry['questionID'])) {
|
||||
$questionIDs[] = (string)$qEntry['questionID'];
|
||||
}
|
||||
}
|
||||
if ($questionIDs === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||
$aStmt = $pdo->prepare(
|
||||
"SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
|
||||
ao.defaultText AS optionKey
|
||||
FROM client_answer ca
|
||||
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
|
||||
WHERE ca.clientCode = ? AND ca.questionID IN ($ph)"
|
||||
);
|
||||
$aStmt->execute(array_merge([$clientCode], $questionIDs));
|
||||
$answersByQuestion = [];
|
||||
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$answersByQuestion[$a['questionID']] = $a;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
$optionPoints = [];
|
||||
foreach ($qEntry['options'] ?? [] as $opt) {
|
||||
if (is_array($opt) && isset($opt['key'])) {
|
||||
$optionPoints[(string)$opt['key']] = (int)($opt['points'] ?? 0);
|
||||
}
|
||||
}
|
||||
$qRow = [
|
||||
'questionID' => $fullId,
|
||||
'type' => $type,
|
||||
'configJson' => json_encode(
|
||||
['symptoms' => $qEntry['symptoms'] ?? []],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
),
|
||||
];
|
||||
$total += qdb_score_points_for_question(
|
||||
$pdo,
|
||||
$qRow,
|
||||
$answersByQuestion[$fullId] ?? null,
|
||||
$optionPoints
|
||||
);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
303
lib/read_db_cache.php
Normal file
303
lib/read_db_cache.php
Normal file
@ -0,0 +1,303 @@
|
||||
<?php
|
||||
/**
|
||||
* Per-worker read-only DB snapshot in RAM (memfd on Linux) — no plaintext on durable disk.
|
||||
*/
|
||||
|
||||
/** Passed as $tmpDb when the PDO comes from the worker cache (qdb_discard is a no-op). */
|
||||
define('QDB_READONLY_CACHE_HANDLE', "\0qdb_cached_read");
|
||||
|
||||
define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation');
|
||||
|
||||
/**
|
||||
* @return array{0: PDO, 1: string, 2: null}
|
||||
*/
|
||||
function qdb_read_db_open(): array {
|
||||
if (qdb_is_mysql()) {
|
||||
return qdb_mysql_open();
|
||||
}
|
||||
|
||||
$signature = qdb_read_cache_signature();
|
||||
$cached = qdb_read_cache_get($signature);
|
||||
if ($cached !== null) {
|
||||
return [$cached, QDB_READONLY_CACHE_HANDLE, null];
|
||||
}
|
||||
|
||||
[$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db();
|
||||
// Materialize may persist a migration (updates QDB_PATH mtime); refresh signature before caching.
|
||||
$signature = qdb_read_cache_signature();
|
||||
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
|
||||
return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
|
||||
}
|
||||
|
||||
function qdb_read_cache_invalidate(): void {
|
||||
qdb_read_cache_reset();
|
||||
qdb_read_generation_bump();
|
||||
}
|
||||
|
||||
function qdb_read_cache_signature(): string {
|
||||
$gen = qdb_read_generation_current();
|
||||
if (!is_file(QDB_PATH)) {
|
||||
return $gen . ':missing';
|
||||
}
|
||||
$mtime = @filemtime(QDB_PATH);
|
||||
$size = @filesize(QDB_PATH);
|
||||
return $gen . ':' . (int)$mtime . ':' . (int)$size;
|
||||
}
|
||||
|
||||
function qdb_read_generation_current(): string {
|
||||
if (!is_file(QDB_READ_GENERATION_FILE)) {
|
||||
return '0';
|
||||
}
|
||||
$raw = @file_get_contents(QDB_READ_GENERATION_FILE);
|
||||
return $raw === false || $raw === '' ? '0' : trim($raw);
|
||||
}
|
||||
|
||||
function qdb_read_generation_bump(): void {
|
||||
$dir = dirname(QDB_READ_GENERATION_FILE);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
$next = (string)((int)qdb_read_generation_current() + 1);
|
||||
@file_put_contents(QDB_READ_GENERATION_FILE, $next, LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: PDO, 1: string, 2: int} memfdPath empty when using disk fallback
|
||||
*/
|
||||
function qdb_read_materialize_decrypted_db(): array {
|
||||
$dbPath = QDB_PATH;
|
||||
$masterKey = get_master_key_bytes();
|
||||
$sql = file_get_contents(QDB_SCHEMA);
|
||||
if ($sql === false) {
|
||||
throw new Exception('Could not read schema.sql');
|
||||
}
|
||||
|
||||
if (!file_exists($dbPath) || !is_file($dbPath)) {
|
||||
$mem = qdb_read_open_sqlite_on_path(':memory:', $sql, true);
|
||||
return [$mem, '', -1];
|
||||
}
|
||||
|
||||
$storedEnc = @file_get_contents($dbPath);
|
||||
if ($storedEnc === false) {
|
||||
throw new Exception('Could not read stored DB');
|
||||
}
|
||||
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
|
||||
|
||||
$memfd = qdb_memfd_write($decrypted);
|
||||
if ($memfd !== null) {
|
||||
[$fd, $path] = $memfd;
|
||||
try {
|
||||
$pdo = qdb_read_open_sqlite_on_path($path, $sql, false);
|
||||
return [$pdo, $path, $fd];
|
||||
} catch (Throwable $e) {
|
||||
// memfd is filled but SQLite cannot open /proc/self/fd on this host.
|
||||
qdb_close_fd($fd);
|
||||
}
|
||||
}
|
||||
|
||||
$snapshotPath = qdb_read_ram_snapshot_path();
|
||||
if (file_put_contents($snapshotPath, $decrypted) === false) {
|
||||
throw new Exception('Could not write read snapshot');
|
||||
}
|
||||
@chmod($snapshotPath, 0600);
|
||||
$pdo = qdb_read_open_sqlite_on_path($snapshotPath, $sql, false);
|
||||
return [$pdo, $snapshotPath, -1];
|
||||
}
|
||||
|
||||
/**
|
||||
* RAM-backed snapshot path (tmpfs). One file per worker; overwritten on refresh.
|
||||
*/
|
||||
function qdb_read_ram_snapshot_path(): string {
|
||||
$dir = is_writable('/dev/shm') ? '/dev/shm' : sys_get_temp_dir();
|
||||
return $dir . '/qdb_read_' . getmypid() . '.sqlite';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FFI|null libc handle with memfd_create + close (Linux only)
|
||||
*/
|
||||
function qdb_linux_libc_ffi() {
|
||||
static $ffi = null;
|
||||
if ($ffi !== null) {
|
||||
return $ffi;
|
||||
}
|
||||
if (PHP_OS_FAMILY !== 'Linux' || !extension_loaded('ffi')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$ffi = FFI::cdef(
|
||||
'int memfd_create(const char *name, unsigned int flags);
|
||||
long write(int fd, const void *buf, unsigned long count);
|
||||
int close(int fd);',
|
||||
'libc.so.6'
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log('qdb_linux_libc_ffi: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
return $ffi;
|
||||
}
|
||||
|
||||
function qdb_close_fd(int $fd): void {
|
||||
if ($fd < 0) {
|
||||
return;
|
||||
}
|
||||
if (function_exists('posix_close')) {
|
||||
@posix_close($fd);
|
||||
return;
|
||||
}
|
||||
$ffi = qdb_linux_libc_ffi();
|
||||
if ($ffi !== null) {
|
||||
$ffi->close($fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a raw fd (e.g. memfd). /proc/self/fd/N writes are blocked on some hosts;
|
||||
* fall back to libc write() via FFI.
|
||||
*/
|
||||
function qdb_memfd_fill_fd(int $fd, string $bytes): bool {
|
||||
$len = strlen($bytes);
|
||||
if ($len === 0) {
|
||||
return true;
|
||||
}
|
||||
$written = @file_put_contents('/proc/self/fd/' . $fd, $bytes);
|
||||
if ($written === $len) {
|
||||
return true;
|
||||
}
|
||||
$ffi = qdb_linux_libc_ffi();
|
||||
if ($ffi === null) {
|
||||
return false;
|
||||
}
|
||||
$offset = 0;
|
||||
while ($offset < $len) {
|
||||
$chunkLen = min(1024 * 1024, $len - $offset);
|
||||
$chunk = substr($bytes, $offset, $chunkLen);
|
||||
$buf = FFI::new('char[' . $chunkLen . ']');
|
||||
FFI::memcpy($buf, $chunk, $chunkLen);
|
||||
$n = (int)$ffi->write($fd, $buf, $chunkLen);
|
||||
if ($n <= 0) {
|
||||
return false;
|
||||
}
|
||||
$offset += $n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string}|null [fd, /proc/self/fd/N]
|
||||
*/
|
||||
function qdb_memfd_write(string $bytes): ?array {
|
||||
$ffi = qdb_linux_libc_ffi();
|
||||
if ($ffi === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$MFD_CLOEXEC = 1;
|
||||
$fd = (int)$ffi->memfd_create('qdb_read', $MFD_CLOEXEC);
|
||||
if ($fd < 0) {
|
||||
return null;
|
||||
}
|
||||
if (!qdb_memfd_fill_fd($fd, $bytes)) {
|
||||
qdb_close_fd($fd);
|
||||
return null;
|
||||
}
|
||||
return [$fd, '/proc/self/fd/' . $fd];
|
||||
} catch (Throwable $e) {
|
||||
error_log('qdb_memfd_write: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_read_open_sqlite_on_path(string $sqlitePath, string $schemaSql, bool $fresh): PDO {
|
||||
if ($fresh) {
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->exec($schemaSql);
|
||||
$pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';');
|
||||
} else {
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
// DELETE (not WAL) so a migration persist via file_get_contents + qdb_save is complete.
|
||||
$pdo->exec('PRAGMA journal_mode = DELETE;');
|
||||
|
||||
$schemaChanged = qdb_apply_migrations($pdo, $schemaSql);
|
||||
if ($schemaChanged) {
|
||||
$migrateLock = qdb_acquire_lock();
|
||||
try {
|
||||
if ($sqlitePath === ':memory:') {
|
||||
throw new Exception('Cannot persist migration from in-memory read snapshot');
|
||||
}
|
||||
$savePath = $sqlitePath;
|
||||
if (str_starts_with($sqlitePath, '/proc/')) {
|
||||
$savePath = tempnam(sys_get_temp_dir(), 'qdb_migrate_');
|
||||
$bytes = file_get_contents($sqlitePath);
|
||||
if ($bytes === false || file_put_contents($savePath, $bytes) === false) {
|
||||
throw new Exception('Could not copy memfd DB for migration save');
|
||||
}
|
||||
} elseif (!is_file($savePath)) {
|
||||
throw new Exception('Cannot persist migration from missing read snapshot');
|
||||
}
|
||||
$pdo->exec('PRAGMA wal_checkpoint(FULL);');
|
||||
qdb_save($savePath, $migrateLock);
|
||||
if ($savePath !== $sqlitePath && is_file($savePath)) {
|
||||
@unlink($savePath);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
flock($migrateLock, LOCK_UN);
|
||||
fclose($migrateLock);
|
||||
throw $e;
|
||||
}
|
||||
return qdb_read_db_open()[0];
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PDO|null
|
||||
*/
|
||||
function qdb_read_cache_get(string $signature) {
|
||||
$state = &qdb_read_cache_state();
|
||||
if ($state['signature'] === $signature && $state['pdo'] instanceof PDO) {
|
||||
return $state['pdo'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function qdb_read_cache_store(string $signature, PDO $pdo, string $memfdPath, int $memfdFd): void {
|
||||
qdb_read_cache_reset();
|
||||
$state = &qdb_read_cache_state();
|
||||
$state['signature'] = $signature;
|
||||
$state['pdo'] = $pdo;
|
||||
$state['memfdPath'] = $memfdPath;
|
||||
$state['memfdFd'] = $memfdFd;
|
||||
}
|
||||
|
||||
function qdb_read_cache_reset(): void {
|
||||
$state = &qdb_read_cache_state();
|
||||
$state['signature'] = '';
|
||||
$state['pdo'] = null;
|
||||
if ($state['memfdFd'] >= 0) {
|
||||
qdb_close_fd($state['memfdFd']);
|
||||
}
|
||||
$state['memfdFd'] = -1;
|
||||
if ($state['memfdPath'] !== '' && is_file($state['memfdPath'])) {
|
||||
@unlink($state['memfdPath']);
|
||||
}
|
||||
$state['memfdPath'] = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{signature: string, pdo: ?PDO, memfdPath: string, memfdFd: int}
|
||||
*/
|
||||
function &qdb_read_cache_state(): array {
|
||||
static $state = [
|
||||
'signature' => '',
|
||||
'pdo' => null,
|
||||
'memfdPath' => '',
|
||||
'memfdFd' => -1,
|
||||
];
|
||||
return $state;
|
||||
}
|
||||
@ -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,120 +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,
|
||||
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq
|
||||
ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
WHERE $rbacClause
|
||||
";
|
||||
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
|
||||
|
||||
if ($clientCode) {
|
||||
$sql .= " AND cl.clientCode = :cc";
|
||||
$params[':cc'] = $clientCode;
|
||||
}
|
||||
$sql .= " ORDER BY cl.clientCode";
|
||||
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($questionIDs) && !empty($clients)) {
|
||||
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
foreach ($clients as &$c) {
|
||||
self::castClientInts($c);
|
||||
|
||||
$answerStmt->execute(array_merge([$c['clientCode']], $questionIDs));
|
||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) {
|
||||
$answerMap[$a['questionID']] = [
|
||||
'answerOptionID' => $a['answerOptionID'],
|
||||
'freeTextValue' => $a['freeTextValue'],
|
||||
'numericValue' => $a['numericValue'] !== null ? (float) $a['numericValue'] : null,
|
||||
'answeredAt' => $a['answeredAt'] !== null ? (int) $a['answeredAt'] : null,
|
||||
];
|
||||
}
|
||||
$c['answers'] = $answerMap;
|
||||
}
|
||||
unset($c);
|
||||
} else {
|
||||
foreach ($clients as &$c) {
|
||||
self::castClientInts($c);
|
||||
$c['answers'] = (object) [];
|
||||
}
|
||||
unset($c);
|
||||
}
|
||||
|
||||
return [
|
||||
'questionnaire' => $questionnaire,
|
||||
'questions' => $questions,
|
||||
'clients' => $clients,
|
||||
];
|
||||
}
|
||||
|
||||
private static function castClientInts(array &$c): void
|
||||
{
|
||||
$c['sumPoints'] = $c['sumPoints'] !== null ? (int) $c['sumPoints'] : null;
|
||||
$c['startedAt'] = $c['startedAt'] !== null ? (int) $c['startedAt'] : null;
|
||||
$c['completedAt'] = $c['completedAt'] !== null ? (int) $c['completedAt'] : null;
|
||||
}
|
||||
}
|
||||
@ -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() ?: '';
|
||||
}
|
||||
}
|
||||
106
lib/response.php
106
lib/response.php
@ -1,24 +1,122 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/encrypted_payload.php';
|
||||
|
||||
/** @internal PHPUnit: inject request body (cleared by qdb_test_reset_http_state). */
|
||||
function qdb_test_set_request_body(string $body): void
|
||||
{
|
||||
if (!defined('QDB_TESTING')) {
|
||||
throw new RuntimeException('qdb_test_set_request_body is only available in tests');
|
||||
}
|
||||
$GLOBALS['qdb_test_request_body'] = $body;
|
||||
}
|
||||
|
||||
/** @internal PHPUnit: reset cached body between API calls. */
|
||||
function qdb_test_reset_http_state(): void
|
||||
{
|
||||
if (!defined('QDB_TESTING')) {
|
||||
return;
|
||||
}
|
||||
unset($GLOBALS['qdb_test_request_body']);
|
||||
}
|
||||
|
||||
/** @internal PHPUnit: rethrow response control-flow exceptions. */
|
||||
function qdb_test_is_control_flow_exception(\Throwable $e): bool
|
||||
{
|
||||
return $e instanceof \Tests\Support\JsonSuccessResponse
|
||||
|| $e instanceof \Tests\Support\JsonErrorResponse
|
||||
|| $e instanceof \Tests\Support\RawHttpResponse;
|
||||
}
|
||||
|
||||
function json_success(mixed $data, int $status = 200): never {
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\JsonSuccessResponse($data, $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode(["ok" => true, "data" => $data]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_error(string $code, string $message, int $status = 400): never {
|
||||
/**
|
||||
* @param mixed $details Optional structured payload (e.g. validation error list).
|
||||
*/
|
||||
/**
|
||||
* Send a non-JSON download response (CSV, ZIP, etc.).
|
||||
*
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
function qdb_http_download(string $body, array $headers, int $status = 200): never
|
||||
{
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\RawHttpResponse($body, $headers, $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode(["ok" => false, "error" => ["code" => $code, "message" => $message]]);
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value);
|
||||
}
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_error(string $code, string $message, int $status = 400, mixed $details = null): never {
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\JsonErrorResponse($code, $message, $status, $details);
|
||||
}
|
||||
if (function_exists('qdb_api_log_note_api_error')) {
|
||||
qdb_api_log_note_api_error($code, $message, $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
$error = ["code" => $code, "message" => $message];
|
||||
if ($details !== null) {
|
||||
$error['details'] = $details;
|
||||
}
|
||||
echo json_encode(["ok" => false, "error" => $error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Raw request body (cached; php://input is single-read). */
|
||||
function qdb_raw_request_body(): string {
|
||||
if (defined('QDB_TESTING') && array_key_exists('qdb_test_request_body', $GLOBALS)) {
|
||||
return (string)$GLOBALS['qdb_test_request_body'];
|
||||
}
|
||||
static $raw = null;
|
||||
if ($raw === null) {
|
||||
$read = file_get_contents('php://input');
|
||||
$raw = $read === false ? '' : $read;
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
function read_json_body(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
$raw = qdb_raw_request_body();
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
947
lib/scoring.php
Normal file
947
lib/scoring.php
Normal file
@ -0,0 +1,947 @@
|
||||
<?php
|
||||
/**
|
||||
* Questionnaire scoring engine and scoring-profile aggregation.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/app_answers.php';
|
||||
|
||||
/** Default glass-scale level keys and implicit points (0–4). */
|
||||
function qdb_glass_scale_level_keys(): array {
|
||||
return ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
|
||||
}
|
||||
|
||||
function qdb_default_glass_level_points(): array {
|
||||
return [
|
||||
'never_glass' => 0,
|
||||
'little_glass' => 1,
|
||||
'moderate_glass' => 2,
|
||||
'much_glass' => 3,
|
||||
'extreme_glass' => 4,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_make_score_rule_id(string $questionID, string $scopeKey, string $levelKey): string {
|
||||
return substr(hash('sha256', $questionID . "\0" . $scopeKey . "\0" . $levelKey), 0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_get_score_rules_for_question(PDO $pdo, string $questionID): array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT scopeKey, levelKey, points FROM question_score_rule WHERE questionID = :qid ORDER BY scopeKey, levelKey'
|
||||
);
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$rows[] = [
|
||||
'scopeKey' => (string)($r['scopeKey'] ?? ''),
|
||||
'levelKey' => (string)($r['levelKey'] ?? ''),
|
||||
'points' => (int)($r['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{scopeKey?: string, levelKey: string, points: int}> $rules
|
||||
*/
|
||||
function qdb_sync_question_score_rules(PDO $pdo, string $questionID, array $rules): void {
|
||||
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :qid')
|
||||
->execute([':qid' => $questionID]);
|
||||
if ($rules === []) {
|
||||
return;
|
||||
}
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO question_score_rule (ruleID, questionID, scopeKey, levelKey, points)
|
||||
VALUES (:rid, :qid, :sk, :lk, :pts)'
|
||||
);
|
||||
foreach ($rules as $rule) {
|
||||
$scopeKey = trim((string)($rule['scopeKey'] ?? ''));
|
||||
$levelKey = trim((string)($rule['levelKey'] ?? ''));
|
||||
if ($levelKey === '') {
|
||||
continue;
|
||||
}
|
||||
$ins->execute([
|
||||
':rid' => qdb_make_score_rule_id($questionID, $scopeKey, $levelKey),
|
||||
':qid' => $questionID,
|
||||
':sk' => $scopeKey,
|
||||
':lk' => $levelKey,
|
||||
':pts' => (int)($rule['points'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested map: scopeKey -> levelKey -> points.
|
||||
*
|
||||
* @return array<string, array<string, int>>
|
||||
*/
|
||||
function qdb_score_rule_map_for_question(PDO $pdo, string $questionID): array {
|
||||
$map = [];
|
||||
foreach (qdb_get_score_rules_for_question($pdo, $questionID) as $rule) {
|
||||
$map[$rule['scopeKey']][$rule['levelKey']] = $rule['points'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scoreRules to glass symptom rows for editor API.
|
||||
*
|
||||
* @return list<array{key: string, labelDe: string, scoreLevels: array<string, int>}>
|
||||
*/
|
||||
function qdb_glass_symptoms_with_score_rules(PDO $pdo, string $questionID, array $config): array {
|
||||
$ruleMap = $questionID !== '' ? qdb_score_rule_map_for_question($pdo, $questionID) : [];
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
$rows = [];
|
||||
foreach (qdb_glass_symptoms_with_labels($pdo, $config) as $row) {
|
||||
$key = $row['key'];
|
||||
$levels = [];
|
||||
foreach (qdb_glass_scale_level_keys() as $levelKey) {
|
||||
$levels[$levelKey] = $ruleMap[$key][$levelKey]
|
||||
?? $ruleMap[''][$levelKey]
|
||||
?? $defaults[$levelKey]
|
||||
?? 0;
|
||||
}
|
||||
$rows[] = [
|
||||
'key' => $key,
|
||||
'labelDe' => $row['labelDe'],
|
||||
'scoreLevels' => $levels,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse scoreRules from question save body.
|
||||
*
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_parse_score_rules_body(array $body, string $questionType, array $config): array {
|
||||
if (!isset($body['scoreRules']) || !is_array($body['scoreRules'])) {
|
||||
if ($questionType === 'glass_scale_question' && isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) {
|
||||
return qdb_score_rules_from_glass_symptoms($body['glassSymptoms']);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
$rules = [];
|
||||
foreach ($body['scoreRules'] as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$levelKey = trim((string)($row['levelKey'] ?? ''));
|
||||
if ($levelKey === '') {
|
||||
continue;
|
||||
}
|
||||
$rules[] = [
|
||||
'scopeKey' => trim((string)($row['scopeKey'] ?? '')),
|
||||
'levelKey' => $levelKey,
|
||||
'points' => (int)($row['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{key?: string, scoreLevels?: array<string, int>}> $glassSymptoms
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_score_rules_from_glass_symptoms(array $glassSymptoms): array {
|
||||
$rules = [];
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
foreach ($glassSymptoms as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$scopeKey = trim((string)($row['key'] ?? ''));
|
||||
if ($scopeKey === '') {
|
||||
continue;
|
||||
}
|
||||
$levels = is_array($row['scoreLevels'] ?? null) ? $row['scoreLevels'] : $defaults;
|
||||
foreach (qdb_glass_scale_level_keys() as $levelKey) {
|
||||
$rules[] = [
|
||||
'scopeKey' => $scopeKey,
|
||||
'levelKey' => $levelKey,
|
||||
'points' => (int)($levels[$levelKey] ?? $defaults[$levelKey] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
function qdb_normalize_scoring_bands(array $row): array {
|
||||
$greenMax = (int)($row['greenMax'] ?? 12);
|
||||
$yellowMax = (int)($row['yellowMax'] ?? 36);
|
||||
return [
|
||||
'greenMin' => (int)($row['greenMin'] ?? 0),
|
||||
'greenMax' => $greenMax,
|
||||
'yellowMin' => (int)($row['yellowMin'] ?? ($greenMax + 1)),
|
||||
'yellowMax' => $yellowMax,
|
||||
'redMin' => (int)($row['redMin'] ?? ($yellowMax + 1)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null Error message, or null if valid.
|
||||
*/
|
||||
function qdb_validate_scoring_bands(array $bands): ?string {
|
||||
if ($bands['greenMin'] > $bands['greenMax']) {
|
||||
return 'Green min must not exceed green max';
|
||||
}
|
||||
if ($bands['yellowMin'] > $bands['yellowMax']) {
|
||||
return 'Yellow min must not exceed yellow max';
|
||||
}
|
||||
if ($bands['greenMax'] >= $bands['yellowMin']) {
|
||||
return 'Green range must end before yellow range starts';
|
||||
}
|
||||
if ($bands['yellowMax'] >= $bands['redMin']) {
|
||||
return 'Yellow range must end before red range starts';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int>|int $bandsOrGreenMax Band map or legacy greenMax
|
||||
*/
|
||||
function qdb_band_for_total(float $total, array|int $bandsOrGreenMax, ?int $yellowMax = null): string {
|
||||
$bands = is_array($bandsOrGreenMax)
|
||||
? qdb_normalize_scoring_bands($bandsOrGreenMax)
|
||||
: qdb_normalize_scoring_bands(['greenMax' => $bandsOrGreenMax, 'yellowMax' => $yellowMax ?? 36]);
|
||||
|
||||
if ($total >= $bands['greenMin'] && $total <= $bands['greenMax']) {
|
||||
return 'green';
|
||||
}
|
||||
if ($total >= $bands['yellowMin'] && $total <= $bands['yellowMax']) {
|
||||
return 'yellow';
|
||||
}
|
||||
if ($total >= $bands['redMin']) {
|
||||
return 'red';
|
||||
}
|
||||
if ($total < $bands['greenMin']) {
|
||||
return 'green';
|
||||
}
|
||||
if ($total < $bands['yellowMin']) {
|
||||
return 'yellow';
|
||||
}
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function qdb_parse_scoring_bands_body(array $body): array {
|
||||
$greenMax = (int)($body['greenMax'] ?? 12);
|
||||
$yellowMax = (int)($body['yellowMax'] ?? 36);
|
||||
return qdb_normalize_scoring_bands([
|
||||
'greenMin' => (int)($body['greenMin'] ?? 0),
|
||||
'greenMax' => $greenMax,
|
||||
'yellowMin' => (int)($body['yellowMin'] ?? ($greenMax + 1)),
|
||||
'yellowMax' => $yellowMax,
|
||||
'redMin' => (int)($body['redMin'] ?? ($yellowMax + 1)),
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_scoring_bands_to_api(array $row): array {
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
return $bands;
|
||||
}
|
||||
|
||||
function qdb_compute_questionnaire_score(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
|
||||
);
|
||||
$qStmt->execute([':qn' => $questionnaireID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT ao.questionID, ao.defaultText, ao.points
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn'
|
||||
);
|
||||
$aoStmt->execute([':qn' => $questionnaireID]);
|
||||
$optionPoints = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionPoints[$ao['questionID']][$ao['defaultText']] = (int)$ao['points'];
|
||||
}
|
||||
|
||||
$aStmt = $pdo->prepare(
|
||||
'SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
|
||||
ao.defaultText AS optionKey
|
||||
FROM client_answer ca
|
||||
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
|
||||
JOIN question q ON q.questionID = ca.questionID
|
||||
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn'
|
||||
);
|
||||
$aStmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||
$answersByQuestion = [];
|
||||
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$answersByQuestion[$a['questionID']] = $a;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($questions as $qRow) {
|
||||
$total += qdb_score_points_for_question(
|
||||
$pdo,
|
||||
$qRow,
|
||||
$answersByQuestion[$qRow['questionID']] ?? null,
|
||||
$optionPoints[$qRow['questionID']] ?? []
|
||||
);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
function qdb_score_points_for_question(
|
||||
PDO $pdo,
|
||||
array $questionRow,
|
||||
?array $answerRow,
|
||||
array $optionPointsMap,
|
||||
): int {
|
||||
if ($answerRow === null) {
|
||||
return 0;
|
||||
}
|
||||
$type = (string)($questionRow['type'] ?? '');
|
||||
$questionID = (string)($questionRow['questionID'] ?? '');
|
||||
$config = json_decode($questionRow['configJson'] ?? '{}', true) ?: [];
|
||||
$ruleMap = qdb_score_rule_map_for_question($pdo, $questionID);
|
||||
|
||||
switch ($type) {
|
||||
case 'radio_question':
|
||||
case 'string_spinner':
|
||||
$key = trim((string)($answerRow['optionKey'] ?? $answerRow['freeTextValue'] ?? ''));
|
||||
return $key !== '' ? (int)($optionPointsMap[$key] ?? 0) : 0;
|
||||
|
||||
case 'multi_check_box_question':
|
||||
$raw = trim((string)($answerRow['freeTextValue'] ?? ''));
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
$sum = 0;
|
||||
foreach (qdb_decode_multi_check_values($raw) as $mk) {
|
||||
$sum += (int)($optionPointsMap[$mk] ?? 0);
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'glass_scale_question':
|
||||
$json = trim((string)($answerRow['freeTextValue'] ?? ''));
|
||||
if ($json === '') {
|
||||
return 0;
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
return 0;
|
||||
}
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
$sum = 0;
|
||||
foreach ($decoded as $symptomKey => $levelKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
$lk = trim((string)$levelKey);
|
||||
if ($sk === '' || $lk === '') {
|
||||
continue;
|
||||
}
|
||||
$sum += (int)($ruleMap[$sk][$lk]
|
||||
?? $ruleMap[''][$lk]
|
||||
?? $defaults[$lk]
|
||||
?? 0);
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'slider_question':
|
||||
case 'value_spinner':
|
||||
$value = $answerRow['numericValue'];
|
||||
if ($value === null || $value === '') {
|
||||
$value = $answerRow['freeTextValue'];
|
||||
}
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
$levelKey = is_numeric($value)
|
||||
? (string)(int)round((float)$value)
|
||||
: trim((string)$value);
|
||||
return (int)($ruleMap[''][$levelKey] ?? 0);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ?string $triggerQuestionnaireID = null): void {
|
||||
$profileSql = 'SELECT profileID, name, greenMin, greenMax, yellowMin, yellowMax, redMin FROM scoring_profile WHERE isActive = 1';
|
||||
$params = [];
|
||||
if ($triggerQuestionnaireID !== null && $triggerQuestionnaireID !== '') {
|
||||
$profileSql = '
|
||||
SELECT sp.profileID, sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
|
||||
FROM scoring_profile sp
|
||||
JOIN scoring_profile_questionnaire spq ON spq.profileID = sp.profileID
|
||||
WHERE sp.isActive = 1 AND spq.questionnaireID = :qn';
|
||||
$params[':qn'] = $triggerQuestionnaireID;
|
||||
}
|
||||
$pStmt = $pdo->prepare($profileSql);
|
||||
$pStmt->execute($params);
|
||||
$profiles = $pStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$deleteOrphan = $pdo->prepare(
|
||||
'DELETE FROM client_scoring_profile_result WHERE clientCode = :cc AND profileID = :pid'
|
||||
);
|
||||
$upsert = $pdo->prepare(
|
||||
'INSERT INTO client_scoring_profile_result
|
||||
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot)
|
||||
VALUES (:cc, :pid, :wt, :band, :at, :snap) '
|
||||
. qdb_upsert_update(
|
||||
['clientCode', 'profileID'],
|
||||
[
|
||||
'weightedTotal' => 'excluded.weightedTotal',
|
||||
'band' => 'excluded.band',
|
||||
'computedAt' => 'excluded.computedAt',
|
||||
'questionnaireSnapshot' => 'excluded.questionnaireSnapshot',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$profileID = $profile['profileID'];
|
||||
$members = qdb_scoring_profile_members($pdo, $profileID);
|
||||
if ($members === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = [];
|
||||
$weightedTotal = 0.0;
|
||||
$allComplete = true;
|
||||
|
||||
foreach ($members as $member) {
|
||||
$qnID = $member['questionnaireID'];
|
||||
$weight = (float)$member['weight'];
|
||||
$cq = $pdo->prepare(
|
||||
"SELECT status, sumPoints FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn"
|
||||
);
|
||||
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$row = $cq->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || ($row['status'] ?? '') !== 'completed') {
|
||||
$allComplete = false;
|
||||
break;
|
||||
}
|
||||
$sumPoints = (int)($row['sumPoints'] ?? 0);
|
||||
$contribution = $sumPoints * $weight;
|
||||
$weightedTotal += $contribution;
|
||||
$snapshot[$qnID] = [
|
||||
'sumPoints' => $sumPoints,
|
||||
'weight' => $weight,
|
||||
'contribution' => round($contribution, 4),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$allComplete) {
|
||||
$deleteOrphan->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$band = qdb_band_for_total($weightedTotal, qdb_normalize_scoring_bands($profile));
|
||||
$upsert->execute([
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
':wt' => round($weightedTotal, 4),
|
||||
':band' => $band,
|
||||
':at' => time(),
|
||||
':snap' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionnaireID: string, weight: float, orderIndex: int, name?: string}>
|
||||
*/
|
||||
function qdb_scoring_profile_members(PDO $pdo, string $profileID): array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT spq.questionnaireID, spq.weight, spq.orderIndex, q.name
|
||||
FROM scoring_profile_questionnaire spq
|
||||
JOIN questionnaire q ON q.questionnaireID = spq.questionnaireID
|
||||
WHERE spq.profileID = :pid
|
||||
ORDER BY spq.orderIndex, spq.questionnaireID'
|
||||
);
|
||||
$stmt->execute([':pid' => $profileID]);
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$rows[] = [
|
||||
'questionnaireID' => $r['questionnaireID'],
|
||||
'weight' => (float)$r['weight'],
|
||||
'orderIndex' => (int)$r['orderIndex'],
|
||||
'name' => $r['name'] ?? '',
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_list_scoring_profiles(PDO $pdo): array {
|
||||
$stmt = $pdo->query(
|
||||
'SELECT profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||
processCompleteBands, createdAt, updatedAt
|
||||
FROM scoring_profile ORDER BY name'
|
||||
);
|
||||
$profiles = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$row['isActive'] = (int)$row['isActive'];
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
$row = array_merge($row, $bands);
|
||||
$row['processCompleteBands'] = qdb_row_process_complete_bands($row);
|
||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
|
||||
$profiles[] = $row;
|
||||
}
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||
processCompleteBands, createdAt, updatedAt
|
||||
FROM scoring_profile WHERE profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$row['isActive'] = (int)$row['isActive'];
|
||||
$row = array_merge($row, qdb_normalize_scoring_bands($row));
|
||||
$row['processCompleteBands'] = qdb_row_process_complete_bands($row);
|
||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill default glass score rules (0–4) for all glass questions missing rules.
|
||||
*/
|
||||
function qdb_backfill_glass_score_rules(PDO $pdo): int {
|
||||
$stmt = $pdo->query("SELECT questionID, configJson FROM question WHERE type = 'glass_scale_question'");
|
||||
$count = 0;
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$existing = qdb_get_score_rules_for_question($pdo, $row['questionID']);
|
||||
if ($existing !== []) {
|
||||
continue;
|
||||
}
|
||||
$config = json_decode($row['configJson'] ?? '{}', true) ?: [];
|
||||
$rules = [];
|
||||
foreach ($config['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($defaults as $levelKey => $pts) {
|
||||
$rules[] = ['scopeKey' => $sk, 'levelKey' => $levelKey, 'points' => $pts];
|
||||
}
|
||||
}
|
||||
if ($rules !== []) {
|
||||
qdb_sync_question_score_rules($pdo, $row['questionID'], $rules);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute questionnaire sumPoints and scoring-profile results for specific clients.
|
||||
*
|
||||
* @param list<string> $clientCodes
|
||||
*/
|
||||
function qdb_recompute_scores_for_clients(PDO $pdo, array $clientCodes): int {
|
||||
$clientCodes = array_values(array_unique(array_filter(
|
||||
$clientCodes,
|
||||
static fn($cc) => is_string($cc) && trim($cc) !== ''
|
||||
)));
|
||||
if ($clientCodes === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$n = 0;
|
||||
$qnStmt = $pdo->prepare(
|
||||
"SELECT questionnaireID FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND status = 'completed'"
|
||||
);
|
||||
$upd = $pdo->prepare(
|
||||
'UPDATE completed_questionnaire SET sumPoints = :sp WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
foreach ($clientCodes as $clientCode) {
|
||||
$qnStmt->execute([':cc' => $clientCode]);
|
||||
foreach ($qnStmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) {
|
||||
$score = qdb_compute_questionnaire_score($pdo, $clientCode, (string)$qnID);
|
||||
$upd->execute([
|
||||
':sp' => $score,
|
||||
':cc' => $clientCode,
|
||||
':qn' => $qnID,
|
||||
]);
|
||||
$n++;
|
||||
}
|
||||
qdb_recompute_profile_scores_for_client($pdo, $clientCode);
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
function qdb_recompute_all_questionnaire_scores(PDO $pdo): int {
|
||||
$stmt = $pdo->query(
|
||||
"SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = 'completed'"
|
||||
);
|
||||
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
return qdb_recompute_scores_for_clients($pdo, $clientCodes);
|
||||
}
|
||||
|
||||
function qdb_seed_default_rhs_scoring_profile(PDO $pdo): ?string {
|
||||
foreach (qdb_list_scoring_profiles($pdo) as $p) {
|
||||
if ($p['name'] === 'RHS Index') {
|
||||
return $p['profileID'];
|
||||
}
|
||||
}
|
||||
$rhsId = 'questionnaire_2_rhs';
|
||||
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
|
||||
$chk->execute([':id' => $rhsId]);
|
||||
if (!$chk->fetch()) {
|
||||
return null;
|
||||
}
|
||||
$profileID = bin2hex(random_bytes(16));
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt)
|
||||
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :pcb, :ca, :ua)'
|
||||
)->execute([
|
||||
':id' => $profileID,
|
||||
':name' => 'RHS Index',
|
||||
':desc' => 'Legacy RHS thresholds (green 0–12, yellow 13–36, red 37+)',
|
||||
':pcb' => '[]',
|
||||
':ca' => $now,
|
||||
':ua' => $now,
|
||||
]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES (:pid, :qn, 1.0, 0)'
|
||||
)->execute([':pid' => $profileID, ':qn' => $rhsId]);
|
||||
return $profileID;
|
||||
}
|
||||
|
||||
function qdb_is_valid_scoring_band(?string $band): bool {
|
||||
return in_array($band, ['green', 'yellow', 'red'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return list<string>
|
||||
*/
|
||||
function qdb_parse_process_complete_bands($raw): array {
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
$raw = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $band) {
|
||||
$b = strtolower(trim((string)$band));
|
||||
if (qdb_is_valid_scoring_band($b)) {
|
||||
$out[] = $b;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function qdb_decode_process_complete_bands(?string $json): array {
|
||||
if ($json === null || trim($json) === '') {
|
||||
return [];
|
||||
}
|
||||
return qdb_parse_process_complete_bands(json_decode($json, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $bands
|
||||
*/
|
||||
function qdb_encode_process_complete_bands(array $bands): string {
|
||||
return json_encode(qdb_parse_process_complete_bands($bands), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return list<string>
|
||||
*/
|
||||
function qdb_row_process_complete_bands(array $row): array {
|
||||
if (array_key_exists('processCompleteBands', $row) && is_array($row['processCompleteBands'])) {
|
||||
return qdb_parse_process_complete_bands($row['processCompleteBands']);
|
||||
}
|
||||
return qdb_decode_process_complete_bands((string)($row['processCompleteBands'] ?? '[]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Coach decision when set; otherwise the server-computed band.
|
||||
*/
|
||||
function qdb_effective_scoring_band(array $row): string {
|
||||
$coach = trim((string)($row['coachBand'] ?? ''));
|
||||
if ($coach !== '' && qdb_is_valid_scoring_band($coach)) {
|
||||
return $coach;
|
||||
}
|
||||
return (string)($row['band'] ?? '');
|
||||
}
|
||||
|
||||
function qdb_scoring_review_row_to_api(array $row, bool $includeComputed = true): array {
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$out = [
|
||||
'profileID' => (string)($row['profileID'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'pendingReview' => $coachBand === '',
|
||||
];
|
||||
if ($includeComputed) {
|
||||
$out['weightedTotal'] = (float)($row['weightedTotal'] ?? 0);
|
||||
$out['calculatedBand'] = (string)($row['band'] ?? '');
|
||||
$out['computedAt'] = !empty($row['computedAt']) ? date('Y-m-d H:i', (int)$row['computedAt']) : '';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $clientCodes
|
||||
* @return list<array{clientCode: string, profiles: list<array<string, mixed>>}>
|
||||
*/
|
||||
function qdb_app_scoring_review_for_clients(PDO $pdo, array $clientCodes): array {
|
||||
if ($clientCodes === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.coachBand, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
WHERE r.clientCode IN ($placeholders)
|
||||
ORDER BY r.clientCode, sp.name"
|
||||
);
|
||||
$stmt->execute(array_values($clientCodes));
|
||||
$byClient = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$byClient[$cc][] = qdb_scoring_review_row_to_api($row, false);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($clientCodes as $code) {
|
||||
$out[] = [
|
||||
'clientCode' => $code,
|
||||
'profiles' => $byClient[$code] ?? [],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* Store coach band; create a result row from app-local computed values when none exists yet.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_save_coach_scoring_review(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $profileID,
|
||||
string $coachBand,
|
||||
string $userID,
|
||||
?float $weightedTotal = null,
|
||||
?string $calculatedBand = null,
|
||||
): array {
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc AND r.profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
if ($weightedTotal === null || $calculatedBand === null || !qdb_is_valid_scoring_band($calculatedBand)) {
|
||||
throw new RuntimeException('Scoring profile result not found; calculatedBand and weightedTotal required');
|
||||
}
|
||||
$profile = qdb_get_scoring_profile($pdo, $profileID);
|
||||
if (!$profile || (int)($profile['isActive'] ?? 0) !== 1) {
|
||||
throw new RuntimeException('Scoring profile not found');
|
||||
}
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO client_scoring_profile_result
|
||||
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot,
|
||||
coachBand, coachReviewedAt, coachReviewedByUserID)
|
||||
VALUES (:cc, :pid, :wt, :band, :at, :snap, :cb, :rat, :uid)'
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
':wt' => round($weightedTotal, 4),
|
||||
':band' => $calculatedBand,
|
||||
':at' => $now,
|
||||
':snap' => '{}',
|
||||
':cb' => $coachBand,
|
||||
':rat' => $now,
|
||||
':uid' => $userID,
|
||||
]);
|
||||
return qdb_scoring_review_row_to_api([
|
||||
'profileID' => $profileID,
|
||||
'name' => $profile['name'],
|
||||
'weightedTotal' => $weightedTotal,
|
||||
'band' => $calculatedBand,
|
||||
'coachBand' => $coachBand,
|
||||
'computedAt' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return qdb_set_coach_scoring_band($pdo, $clientCode, $profileID, $coachBand, $userID);
|
||||
}
|
||||
|
||||
function qdb_set_coach_scoring_band(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $profileID,
|
||||
string $coachBand,
|
||||
string $userID
|
||||
): array {
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc AND r.profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Scoring profile result not found or profile incomplete');
|
||||
}
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'UPDATE client_scoring_profile_result
|
||||
SET coachBand = :cb, coachReviewedAt = :at, coachReviewedByUserID = :uid
|
||||
WHERE clientCode = :cc AND profileID = :pid'
|
||||
)->execute([
|
||||
':cb' => $coachBand,
|
||||
':at' => $now,
|
||||
':uid' => $userID,
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
]);
|
||||
$row['coachBand'] = $coachBand;
|
||||
$row['profileID'] = $profileID;
|
||||
return qdb_scoring_review_row_to_api($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{profileID: string, name: string}>
|
||||
*/
|
||||
function qdb_active_scoring_profiles(PDO $pdo): array {
|
||||
$rows = $pdo->query(
|
||||
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
return array_map(static fn(array $p) => [
|
||||
'profileID' => (string)$p['profileID'],
|
||||
'name' => (string)$p['name'],
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV / results table columns for active scoring profiles.
|
||||
*
|
||||
* @param list<array{profileID: string, name: string}> $profiles
|
||||
* @return list<array{profileID: string, field: string, header: string}>
|
||||
*/
|
||||
function qdb_scoring_export_column_defs(array $profiles): array {
|
||||
$cols = [];
|
||||
foreach ($profiles as $p) {
|
||||
$name = trim((string)($p['name'] ?? ''));
|
||||
$prefix = $name !== '' ? $name : (string)($p['profileID'] ?? '');
|
||||
$profileID = (string)($p['profileID'] ?? '');
|
||||
if ($profileID === '') {
|
||||
continue;
|
||||
}
|
||||
$cols[] = ['profileID' => $profileID, 'field' => 'calculated', 'header' => "{$prefix} calculated category"];
|
||||
$cols[] = ['profileID' => $profileID, 'field' => 'counselor', 'header' => "{$prefix} counselor category"];
|
||||
$cols[] = ['profileID' => $profileID, 'field' => 'override_by', 'header' => "{$prefix} override by"];
|
||||
$cols[] = ['profileID' => $profileID, 'field' => 'override_at', 'header' => "{$prefix} override at"];
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
|
||||
function qdb_scoring_export_cell_value(array $profileResult, string $field): string {
|
||||
$calc = (string)($profileResult['band'] ?? '');
|
||||
$coach = trim((string)($profileResult['coachBand'] ?? ''));
|
||||
$differs = $coach !== '' && $coach !== $calc;
|
||||
return match ($field) {
|
||||
'calculated' => $calc,
|
||||
'counselor' => $coach,
|
||||
'override_by' => $differs ? (string)($profileResult['coachReviewedByUsername'] ?? '') : '',
|
||||
'override_at' => $differs && !empty($profileResult['coachReviewedAt'])
|
||||
? date('Y-m-d H:i', (int)$profileResult['coachReviewedAt']) : '',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{profileID: string, field: string, header: string}> $columnDefs
|
||||
* @param array<string, array<string, mixed>> $byProfile profileID => result row
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function qdb_scoring_export_row_values(array $byProfile, array $columnDefs): array {
|
||||
$row = [];
|
||||
foreach ($columnDefs as $col) {
|
||||
$profileID = (string)($col['profileID'] ?? '');
|
||||
$result = $byProfile[$profileID] ?? null;
|
||||
$row[$col['header']] = is_array($result)
|
||||
? qdb_scoring_export_cell_value($result, (string)($col['field'] ?? ''))
|
||||
: '';
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoring results for export, keyed by clientCode then profileID.
|
||||
*
|
||||
* @param list<string> $clientCodes
|
||||
* @return array<string, array<string, array<string, mixed>>>
|
||||
*/
|
||||
function qdb_scoring_export_results_map(PDO $pdo, array $clientCodes, array $tokenRec): array {
|
||||
if ($clientCodes === []) {
|
||||
return [];
|
||||
}
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
|
||||
u.username AS coachReviewedByUsername
|
||||
FROM client_scoring_profile_result r
|
||||
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||
LEFT JOIN users u ON u.userID = r.coachReviewedByUserID
|
||||
WHERE r.clientCode IN ($placeholders) AND ($rbacClause)"
|
||||
);
|
||||
$stmt->execute(array_merge(array_values($clientCodes), $rbacParams));
|
||||
|
||||
$map = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$map[$cc][(string)$row['profileID']] = $row;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function qdb_scoring_export_csv_headers(PDO $pdo): array {
|
||||
$profiles = qdb_active_scoring_profiles($pdo);
|
||||
return array_column(qdb_scoring_export_column_defs($profiles), 'header');
|
||||
}
|
||||
114
lib/settings.php
Normal file
114
lib/settings.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Persisted security settings (system_setting table in encrypted DB).
|
||||
*/
|
||||
|
||||
function qdb_settings_defaults(): array
|
||||
{
|
||||
return [
|
||||
'login_max_attempts' => 10,
|
||||
'login_window_seconds' => 900,
|
||||
'login_lockout_seconds' => 900,
|
||||
'session_ttl_seconds' => 30 * 24 * 60 * 60,
|
||||
'temp_session_ttl_seconds' => 10 * 60,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_settings_keys(): array
|
||||
{
|
||||
return array_keys(qdb_settings_defaults());
|
||||
}
|
||||
|
||||
function qdb_ensure_system_setting_table(PDO $pdo): void
|
||||
{
|
||||
if (!qdb_table_exists($pdo, 'system_setting')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE system_setting (
|
||||
settingKey TEXT NOT NULL PRIMARY KEY,
|
||||
settingValue TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_settings_get_on_pdo(PDO $pdo): array
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$out = qdb_settings_defaults();
|
||||
$rows = $pdo->query('SELECT settingKey, settingValue FROM system_setting')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$key = (string)($row['settingKey'] ?? '');
|
||||
if ($key === '' || !array_key_exists($key, $out)) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = (int)$row['settingValue'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_settings_get(): array
|
||||
{
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
try {
|
||||
return qdb_settings_get_on_pdo($pdo);
|
||||
} finally {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $partial
|
||||
* @param array<string, int>|null $base existing settings; defaults used when null
|
||||
* @return array<string, int> saved settings
|
||||
*/
|
||||
function qdb_settings_validate_and_merge(array $partial, ?array $base = null): array
|
||||
{
|
||||
$merged = $base ?? qdb_settings_defaults();
|
||||
$limits = [
|
||||
'login_max_attempts' => [1, 100],
|
||||
'login_window_seconds' => [60, 86400],
|
||||
'login_lockout_seconds' => [60, 86400],
|
||||
'session_ttl_seconds' => [3600, 365 * 24 * 60 * 60],
|
||||
'temp_session_ttl_seconds' => [300, 24 * 60 * 60],
|
||||
];
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
if (!array_key_exists($key, $partial)) {
|
||||
continue;
|
||||
}
|
||||
$val = (int)$partial[$key];
|
||||
[$min, $max] = $limits[$key];
|
||||
if ($val < $min || $val > $max) {
|
||||
throw new InvalidArgumentException(
|
||||
"$key must be between $min and $max"
|
||||
);
|
||||
}
|
||||
$merged[$key] = $val;
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $settings full merged settings to persist
|
||||
*/
|
||||
function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO system_setting (settingKey, settingValue)
|
||||
VALUES (:k, :v) '
|
||||
. qdb_upsert_update(['settingKey'], ['settingValue' => 'excluded.settingValue'])
|
||||
);
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
$stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_session_ttl_seconds(?array $settings = null, bool $temp = false): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
return $temp
|
||||
? (int)$settings['temp_session_ttl_seconds']
|
||||
: (int)$settings['session_ttl_seconds'];
|
||||
}
|
||||
226
lib/sql_dialect.php
Normal file
226
lib/sql_dialect.php
Normal file
@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
/** Database driver helpers (SQLite legacy + MySQL). */
|
||||
|
||||
function qdb_driver(): string {
|
||||
if (isset($GLOBALS['qdb_driver_cache'])) {
|
||||
return (string)$GLOBALS['qdb_driver_cache'];
|
||||
}
|
||||
$GLOBALS['qdb_driver_cache'] = qdb_driver_resolve();
|
||||
return (string)$GLOBALS['qdb_driver_cache'];
|
||||
}
|
||||
|
||||
function qdb_reset_driver_cache(): void {
|
||||
unset($GLOBALS['qdb_driver_cache']);
|
||||
}
|
||||
|
||||
function qdb_driver_resolve(): string {
|
||||
$configured = strtolower(trim((string)(qdb_env_get('QDB_DRIVER') ?? '')));
|
||||
if ($configured === 'mysql' || $configured === 'sqlite') {
|
||||
$driver = $configured;
|
||||
return $driver;
|
||||
}
|
||||
if (qdb_env_get('QDB_MYSQL_HOST') || qdb_env_get('QDB_MYSQL_DATABASE')) {
|
||||
$driver = 'mysql';
|
||||
return $driver;
|
||||
}
|
||||
$driver = 'sqlite';
|
||||
return $driver;
|
||||
}
|
||||
|
||||
function qdb_is_mysql(): bool {
|
||||
return qdb_driver() === 'mysql';
|
||||
}
|
||||
|
||||
function qdb_is_sqlite(): bool {
|
||||
return qdb_driver() === 'sqlite';
|
||||
}
|
||||
|
||||
function qdb_schema_file(): string {
|
||||
return qdb_is_mysql()
|
||||
? __DIR__ . '/../schema.mysql.sql'
|
||||
: QDB_SCHEMA;
|
||||
}
|
||||
|
||||
function qdb_mysql_dsn(): string {
|
||||
$host = qdb_env_get('QDB_MYSQL_HOST') ?: '127.0.0.1';
|
||||
$port = qdb_env_get('QDB_MYSQL_PORT') ?: '3306';
|
||||
$db = qdb_env_get('QDB_MYSQL_DATABASE') ?: 'nat-as-db';
|
||||
return "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
|
||||
}
|
||||
|
||||
function qdb_mysql_credentials(): array {
|
||||
return [
|
||||
qdb_env_get('QDB_MYSQL_USER') ?: 'nat_as_db',
|
||||
qdb_env_get('QDB_MYSQL_PASSWORD') ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0: PDO, 1: string, 2: null} */
|
||||
function qdb_mysql_open(): array {
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
[$user, $pass] = qdb_mysql_credentials();
|
||||
$pdo = new PDO(qdb_mysql_dsn(), $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
}
|
||||
return [$pdo, '', null];
|
||||
}
|
||||
|
||||
function qdb_set_foreign_keys(PDO $pdo, bool $enabled): void {
|
||||
if (qdb_is_mysql()) {
|
||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = ' . ($enabled ? '1' : '0'));
|
||||
return;
|
||||
}
|
||||
$pdo->exec('PRAGMA foreign_keys = ' . ($enabled ? 'ON' : 'OFF') . ';');
|
||||
}
|
||||
|
||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||
if (qdb_is_mysql()) {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = :t LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':t' => $table]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :t LIMIT 1"
|
||||
);
|
||||
$stmt->execute([':t' => $table]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_column_exists(PDO $pdo, string $table, string $column): bool {
|
||||
if (qdb_is_mysql()) {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':t' => $table, ':c' => $column]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
|
||||
if (($col['name'] ?? '') === $column) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function qdb_schema_version(PDO $pdo): int {
|
||||
if (qdb_is_mysql()) {
|
||||
if (!qdb_table_exists($pdo, 'schema_version')) {
|
||||
return 0;
|
||||
}
|
||||
return (int)$pdo->query('SELECT version FROM schema_version WHERE id = 1')->fetchColumn();
|
||||
}
|
||||
return (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_set_schema_version(PDO $pdo, int $version): void {
|
||||
if (qdb_is_mysql()) {
|
||||
if (!qdb_table_exists($pdo, 'schema_version')) {
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS schema_version (
|
||||
id INT NOT NULL PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||
);
|
||||
}
|
||||
$pdo->prepare(
|
||||
'INSERT INTO schema_version (id, version) VALUES (1, :v)
|
||||
ON DUPLICATE KEY UPDATE version = VALUES(version)'
|
||||
)->execute([':v' => $version]);
|
||||
return;
|
||||
}
|
||||
$pdo->exec('PRAGMA user_version = ' . (int)$version . ';');
|
||||
}
|
||||
|
||||
function qdb_stmt_affected_rows(PDO $pdo, PDOStatement $stmt): int {
|
||||
if (qdb_is_mysql()) {
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
return (int)$pdo->query('SELECT changes()')->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_create_index_if_not_exists(PDO $pdo, string $name, string $table, string $columnsSql): void {
|
||||
if (qdb_is_mysql()) {
|
||||
if (!qdb_table_exists($pdo, $table)) {
|
||||
return;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':t' => $table, ':i' => $name]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec("CREATE INDEX `{$name}` ON `{$table}` ({$columnsSql})");
|
||||
return;
|
||||
}
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$name} ON {$table}({$columnsSql})");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $conflictColumns
|
||||
*/
|
||||
function qdb_upsert_do_nothing(array $conflictColumns): string {
|
||||
if (qdb_is_mysql()) {
|
||||
$first = $conflictColumns[0];
|
||||
return "ON DUPLICATE KEY UPDATE `{$first}` = `{$first}`";
|
||||
}
|
||||
return 'ON CONFLICT(' . implode(', ', $conflictColumns) . ') DO NOTHING';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $conflictColumns
|
||||
* @param array<string, string> $assignments column => expression (use excluded.col on SQLite)
|
||||
*/
|
||||
function qdb_upsert_update(array $conflictColumns, array $assignments): string {
|
||||
if (qdb_is_mysql()) {
|
||||
$parts = [];
|
||||
foreach ($assignments as $column => $expression) {
|
||||
$mysqlExpr = preg_replace('/\bexcluded\.(\w+)\b/', 'VALUES($1)', $expression) ?? $expression;
|
||||
$parts[] = "`{$column}` = {$mysqlExpr}";
|
||||
}
|
||||
return 'ON DUPLICATE KEY UPDATE ' . implode(', ', $parts);
|
||||
}
|
||||
$parts = [];
|
||||
foreach ($assignments as $column => $expression) {
|
||||
$parts[] = "{$column} = {$expression}";
|
||||
}
|
||||
return 'ON CONFLICT(' . implode(', ', $conflictColumns) . ') DO UPDATE SET ' . implode(', ', $parts);
|
||||
}
|
||||
|
||||
function qdb_exec_schema_file(PDO $pdo, string $schemaPath): void {
|
||||
$sql = file_get_contents($schemaPath);
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException("Could not read schema: {$schemaPath}");
|
||||
}
|
||||
qdb_exec_schema_sql($pdo, $sql);
|
||||
}
|
||||
|
||||
function qdb_exec_schema_sql(PDO $pdo, string $sql): void {
|
||||
if (qdb_is_mysql()) {
|
||||
foreach (qdb_split_sql_statements($sql) as $statement) {
|
||||
$trimmed = trim($statement);
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '--')) {
|
||||
continue;
|
||||
}
|
||||
$pdo->exec($trimmed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
function qdb_split_sql_statements(string $sql): array {
|
||||
$parts = preg_split('/;\s*\n/', $sql) ?: [];
|
||||
return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== ''));
|
||||
}
|
||||
1060
lib/submissions.php
Normal file
1060
lib/submissions.php
Normal file
File diff suppressed because it is too large
Load Diff
61
lib/text_sanitize.php
Normal file
61
lib/text_sanitize.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Sanitize user-entered free text before storage (defense in depth; queries use prepared statements).
|
||||
*/
|
||||
function sanitize_free_text(mixed $value, int $maxLength = 2000): string {
|
||||
if (!is_string($value) && !is_numeric($value)) {
|
||||
return '';
|
||||
}
|
||||
$s = trim((string) $value);
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$maxLength = max(1, min($maxLength, 10000));
|
||||
$s = mb_substr($s, 0, $maxLength);
|
||||
|
||||
// Control characters (keep tab/newline for multiline answers).
|
||||
$s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s) ?? '';
|
||||
|
||||
$s = str_replace(["\0", '--', '/*', '*/', ';'], '', $s);
|
||||
|
||||
$patterns = [
|
||||
'/\bunion\s+select\b/i',
|
||||
'/\bselect\s+.+\s+from\b/i',
|
||||
'/\binsert\s+into\b/i',
|
||||
'/\bupdate\s+.+\s+set\b/i',
|
||||
'/\bdelete\s+from\b/i',
|
||||
'/\bdrop\s+(table|database)\b/i',
|
||||
'/\bexec(\s|\()/i',
|
||||
'/\bxp_\w+/i',
|
||||
'/\bor\s+1\s*=\s*1\b/i',
|
||||
'/\band\s+1\s*=\s*1\b/i',
|
||||
"/'\s*or\s+'/i",
|
||||
'/"\s*or\s+"/i',
|
||||
'/\bchar\s*\(/i',
|
||||
'/\bconcat\s*\(/i',
|
||||
'/\bsleep\s*\(/i',
|
||||
'/\bbenchmark\s*\(/i',
|
||||
];
|
||||
foreach ($patterns as $pattern) {
|
||||
$s = preg_replace($pattern, '', $s) ?? $s;
|
||||
}
|
||||
|
||||
return trim(preg_replace('/\s{2,}/u', ' ', $s) ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sanitized text or calls json_error when nothing safe remains.
|
||||
*/
|
||||
function require_safe_free_text(mixed $value, string $fieldName, int $maxLength = 2000): string {
|
||||
$raw = is_string($value) || is_numeric($value) ? trim((string) $value) : '';
|
||||
if ($raw === '') {
|
||||
json_error('INVALID_FIELD', "$fieldName is required", 400);
|
||||
}
|
||||
$clean = sanitize_free_text($raw, $maxLength);
|
||||
if ($clean === '') {
|
||||
json_error('INVALID_FIELD', "$fieldName contains disallowed content", 400);
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
76
login.php
76
login.php
@ -1,76 +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 ((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"]);
|
||||
}
|
||||
0
logs/api/.gitkeep
Normal file
0
logs/api/.gitkeep
Normal file
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();
|
||||
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"]);
|
||||
30
phpunit.xml
Normal file
30
phpunit.xml
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="integration">
|
||||
<directory>tests/Integration</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<!-- common.php is legacy/shared helpers; coverage focuses on API surface (lib, handlers, db_init). -->
|
||||
<include>
|
||||
<directory suffix=".php">lib</directory>
|
||||
<directory suffix=".php">handlers</directory>
|
||||
<file>db_init.php</file>
|
||||
<file>common.php</file>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory>tests</directory>
|
||||
<directory>vendor</directory>
|
||||
</exclude>
|
||||
</source>
|
||||
</phpunit>
|
||||
@ -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)?"
|
||||
}
|
||||
270
schema.mysql.sql
Normal file
270
schema.mysql.sql
Normal file
@ -0,0 +1,270 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
id INT NOT NULL PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
userID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(191) NOT NULL UNIQUE,
|
||||
passwordHash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
entityID VARCHAR(64) NOT NULL,
|
||||
mustChangePassword TINYINT(1) NOT NULL DEFAULT 1,
|
||||
createdAt BIGINT NOT NULL,
|
||||
CONSTRAINT chk_users_role CHECK (role IN ('admin','supervisor','coach'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
adminID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(191) NOT NULL,
|
||||
location VARCHAR(255) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supervisor (
|
||||
supervisorID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(191) NOT NULL,
|
||||
location VARCHAR(255) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coach (
|
||||
coachID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
supervisorID VARCHAR(64) NOT NULL,
|
||||
username VARCHAR(191) NOT NULL,
|
||||
FOREIGN KEY(supervisorID) REFERENCES supervisor(supervisorID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client (
|
||||
clientCode VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
coachID VARCHAR(64) NOT NULL,
|
||||
archived TINYINT(1) NOT NULL DEFAULT 0,
|
||||
archivedAt BIGINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(coachID) REFERENCES coach(coachID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_category (
|
||||
categoryKey VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
label VARCHAR(255) NOT NULL DEFAULT '',
|
||||
color VARCHAR(32) NOT NULL DEFAULT '#64748b',
|
||||
orderIndex INT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire (
|
||||
questionnaireID VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
state VARCHAR(64) NOT NULL DEFAULT '',
|
||||
orderIndex INT NOT NULL DEFAULT 0,
|
||||
showPoints TINYINT(1) NOT NULL DEFAULT 0,
|
||||
conditionJson LONGTEXT NOT NULL,
|
||||
categoryKey VARCHAR(191) NOT NULL DEFAULT '',
|
||||
structureRevision INT NOT NULL DEFAULT 1,
|
||||
structureChangedAt BIGINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_structure_snapshot (
|
||||
questionnaireID VARCHAR(191) NOT NULL,
|
||||
structureRevision INT NOT NULL,
|
||||
createdAt BIGINT NOT NULL,
|
||||
manifestJson LONGTEXT NOT NULL,
|
||||
PRIMARY KEY (questionnaireID, structureRevision),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question (
|
||||
questionID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
questionnaireID VARCHAR(191) NOT NULL,
|
||||
defaultText LONGTEXT NOT NULL,
|
||||
type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
orderIndex INT NOT NULL DEFAULT 0,
|
||||
isRequired TINYINT(1) NOT NULL DEFAULT 0,
|
||||
configJson LONGTEXT NOT NULL,
|
||||
retiredAt BIGINT NOT NULL DEFAULT 0,
|
||||
retiredInRevision INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS answer_option (
|
||||
answerOptionID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
questionID VARCHAR(64) NOT NULL,
|
||||
defaultText LONGTEXT NOT NULL,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
orderIndex INT NOT NULL DEFAULT 0,
|
||||
nextQuestionId VARCHAR(64) NOT NULL DEFAULT '',
|
||||
retiredAt BIGINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_answer (
|
||||
clientCode VARCHAR(191) NOT NULL,
|
||||
questionID VARCHAR(64) NOT NULL,
|
||||
answerOptionID VARCHAR(64) NULL,
|
||||
freeTextValue LONGTEXT NULL,
|
||||
numericValue DOUBLE NULL,
|
||||
answeredAt BIGINT NULL,
|
||||
PRIMARY KEY (clientCode, questionID),
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
||||
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS completed_questionnaire (
|
||||
clientCode VARCHAR(191) NOT NULL,
|
||||
questionnaireID VARCHAR(191) NOT NULL,
|
||||
assignedByCoach VARCHAR(64) NULL,
|
||||
status VARCHAR(64) NOT NULL DEFAULT '',
|
||||
startedAt BIGINT NULL,
|
||||
completedAt BIGINT NULL,
|
||||
sumPoints INT NULL,
|
||||
PRIMARY KEY (clientCode, questionnaireID),
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_translation (
|
||||
questionID VARCHAR(64) NOT NULL,
|
||||
languageCode VARCHAR(16) NOT NULL,
|
||||
text LONGTEXT NOT NULL,
|
||||
PRIMARY KEY (questionID, languageCode),
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS answer_option_translation (
|
||||
answerOptionID VARCHAR(64) NOT NULL,
|
||||
languageCode VARCHAR(16) NOT NULL,
|
||||
text LONGTEXT NOT NULL,
|
||||
PRIMARY KEY (answerOptionID, languageCode),
|
||||
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS string_translation (
|
||||
stringKey VARCHAR(191) NOT NULL,
|
||||
languageCode VARCHAR(16) NOT NULL,
|
||||
text LONGTEXT NOT NULL,
|
||||
PRIMARY KEY (stringKey, languageCode)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS language (
|
||||
languageCode VARCHAR(16) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL DEFAULT '',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_language_disable (
|
||||
questionnaireID VARCHAR(191) NOT NULL,
|
||||
languageCode VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY (questionnaireID, languageCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
token VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
userID VARCHAR(64) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
entityID VARCHAR(64) NOT NULL DEFAULT '',
|
||||
createdAt BIGINT NOT NULL,
|
||||
expiresAt BIGINT NOT NULL,
|
||||
temp TINYINT(1) NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_submission (
|
||||
submissionID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
clientCode VARCHAR(191) NOT NULL,
|
||||
questionnaireID VARCHAR(191) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
submittedAt BIGINT NOT NULL,
|
||||
submittedByUserID VARCHAR(64) NOT NULL DEFAULT '',
|
||||
submittedByRole VARCHAR(32) NOT NULL DEFAULT '',
|
||||
assignedByCoach VARCHAR(64) NULL,
|
||||
status VARCHAR(64) NOT NULL DEFAULT '',
|
||||
startedAt BIGINT NULL,
|
||||
completedAt BIGINT NULL,
|
||||
sumPoints INT NULL,
|
||||
structureRevision INT NOT NULL DEFAULT 1,
|
||||
structureSnapshotJson LONGTEXT NOT NULL,
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
||||
UNIQUE KEY uq_submission_client_qn_version (clientCode, questionnaireID, version),
|
||||
KEY idx_submission_client_qn (clientCode, questionnaireID),
|
||||
KEY idx_submission_client_time (clientCode, submittedAt)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_answer_submission (
|
||||
submissionID VARCHAR(64) NOT NULL,
|
||||
questionID VARCHAR(64) NOT NULL,
|
||||
answerOptionID VARCHAR(64) NULL,
|
||||
freeTextValue LONGTEXT NULL,
|
||||
numericValue DOUBLE NULL,
|
||||
answeredAt BIGINT NULL,
|
||||
PRIMARY KEY (submissionID, questionID),
|
||||
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
||||
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_followup_note (
|
||||
clientCode VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
note LONGTEXT NOT NULL,
|
||||
updatedByUserID VARCHAR(64) NOT NULL DEFAULT '',
|
||||
updatedAt BIGINT NOT NULL,
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_setting (
|
||||
settingKey VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
settingValue LONGTEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_score_rule (
|
||||
ruleID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
questionID VARCHAR(64) NOT NULL,
|
||||
scopeKey VARCHAR(191) NOT NULL DEFAULT '',
|
||||
levelKey VARCHAR(191) NOT NULL,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_score_rule_question_scope_level (questionID, scopeKey, levelKey),
|
||||
KEY idx_score_rule_question (questionID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scoring_profile (
|
||||
profileID VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
description LONGTEXT NOT NULL,
|
||||
isActive TINYINT(1) NOT NULL DEFAULT 1,
|
||||
greenMin INT NOT NULL DEFAULT 0,
|
||||
greenMax INT NOT NULL DEFAULT 12,
|
||||
yellowMin INT NOT NULL DEFAULT 13,
|
||||
yellowMax INT NOT NULL DEFAULT 36,
|
||||
redMin INT NOT NULL DEFAULT 37,
|
||||
processCompleteBands LONGTEXT NOT NULL,
|
||||
createdAt BIGINT NOT NULL DEFAULT 0,
|
||||
updatedAt BIGINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scoring_profile_questionnaire (
|
||||
profileID VARCHAR(64) NOT NULL,
|
||||
questionnaireID VARCHAR(191) NOT NULL,
|
||||
weight DOUBLE NOT NULL DEFAULT 1.0,
|
||||
orderIndex INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (profileID, questionnaireID),
|
||||
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_scoring_profile_result (
|
||||
clientCode VARCHAR(191) NOT NULL,
|
||||
profileID VARCHAR(64) NOT NULL,
|
||||
weightedTotal DOUBLE NOT NULL DEFAULT 0,
|
||||
band VARCHAR(32) NOT NULL DEFAULT '',
|
||||
computedAt BIGINT NOT NULL DEFAULT 0,
|
||||
questionnaireSnapshot LONGTEXT NOT NULL,
|
||||
coachBand VARCHAR(32) NOT NULL DEFAULT '',
|
||||
coachReviewedAt BIGINT NOT NULL DEFAULT 0,
|
||||
coachReviewedByUserID VARCHAR(64) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (clientCode, profileID),
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
|
||||
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
||||
KEY idx_client_profile_result_profile (profileID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
167
schema.sql
167
schema.sql
@ -32,27 +32,50 @@ CREATE TABLE IF NOT EXISTS coach (
|
||||
CREATE TABLE IF NOT EXISTS client (
|
||||
clientCode TEXT NOT NULL PRIMARY KEY,
|
||||
coachID TEXT NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archivedAt INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(coachID) REFERENCES coach(coachID)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_category (
|
||||
categoryKey TEXT NOT NULL PRIMARY KEY,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#64748b',
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire (
|
||||
questionnaireID TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
showPoints INTEGER NOT NULL DEFAULT 0,
|
||||
conditionJson TEXT NOT NULL DEFAULT '{}'
|
||||
questionnaireID TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
showPoints INTEGER NOT NULL DEFAULT 0,
|
||||
conditionJson TEXT NOT NULL DEFAULT '{}',
|
||||
categoryKey TEXT NOT NULL DEFAULT '',
|
||||
structureRevision INTEGER NOT NULL DEFAULT 1,
|
||||
structureChangedAt INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_structure_snapshot (
|
||||
questionnaireID TEXT NOT NULL,
|
||||
structureRevision INTEGER NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
manifestJson TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (questionnaireID, structureRevision),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question (
|
||||
questionID TEXT NOT NULL PRIMARY KEY,
|
||||
questionnaireID TEXT NOT NULL,
|
||||
defaultText TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
isRequired INTEGER NOT NULL DEFAULT 0,
|
||||
configJson TEXT NOT NULL DEFAULT '{}',
|
||||
questionID TEXT NOT NULL PRIMARY KEY,
|
||||
questionnaireID TEXT NOT NULL,
|
||||
defaultText TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
isRequired INTEGER NOT NULL DEFAULT 0,
|
||||
configJson TEXT NOT NULL DEFAULT '{}',
|
||||
retiredAt INTEGER NOT NULL DEFAULT 0,
|
||||
retiredInRevision INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
|
||||
);
|
||||
|
||||
@ -63,6 +86,7 @@ CREATE TABLE IF NOT EXISTS answer_option (
|
||||
points INTEGER NOT NULL DEFAULT 0,
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
nextQuestionId TEXT NOT NULL DEFAULT '',
|
||||
retiredAt INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID)
|
||||
);
|
||||
|
||||
@ -118,7 +142,16 @@ CREATE TABLE IF NOT EXISTS string_translation (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS language (
|
||||
languageCode TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT ''
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_language_disable (
|
||||
questionnaireID TEXT NOT NULL,
|
||||
languageCode TEXT NOT NULL,
|
||||
PRIMARY KEY (questionnaireID, languageCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
@ -130,3 +163,107 @@ CREATE TABLE IF NOT EXISTS session (
|
||||
expiresAt INTEGER NOT NULL,
|
||||
temp INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_submission (
|
||||
submissionID TEXT NOT NULL PRIMARY KEY,
|
||||
clientCode TEXT NOT NULL,
|
||||
questionnaireID TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
submittedAt INTEGER NOT NULL,
|
||||
submittedByUserID TEXT NOT NULL DEFAULT '',
|
||||
submittedByRole TEXT NOT NULL DEFAULT '',
|
||||
assignedByCoach TEXT,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
startedAt INTEGER,
|
||||
completedAt INTEGER,
|
||||
sumPoints INTEGER,
|
||||
structureRevision INTEGER NOT NULL DEFAULT 1,
|
||||
structureSnapshotJson TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
||||
UNIQUE(clientCode, questionnaireID, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_submission_client_qn
|
||||
ON questionnaire_submission(clientCode, questionnaireID);
|
||||
CREATE INDEX IF NOT EXISTS idx_submission_client_time
|
||||
ON questionnaire_submission(clientCode, submittedAt);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_answer_submission (
|
||||
submissionID TEXT NOT NULL,
|
||||
questionID TEXT NOT NULL,
|
||||
answerOptionID TEXT,
|
||||
freeTextValue TEXT,
|
||||
numericValue REAL,
|
||||
answeredAt INTEGER,
|
||||
PRIMARY KEY (submissionID, questionID),
|
||||
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
||||
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_followup_note (
|
||||
clientCode TEXT NOT NULL PRIMARY KEY,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updatedByUserID TEXT NOT NULL DEFAULT '',
|
||||
updatedAt INTEGER NOT NULL,
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_score_rule (
|
||||
ruleID TEXT NOT NULL PRIMARY KEY,
|
||||
questionID TEXT NOT NULL,
|
||||
scopeKey TEXT NOT NULL DEFAULT '',
|
||||
levelKey TEXT NOT NULL,
|
||||
points INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
|
||||
UNIQUE(questionID, scopeKey, levelKey)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_score_rule_question
|
||||
ON question_score_rule(questionID);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scoring_profile (
|
||||
profileID TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
isActive INTEGER NOT NULL DEFAULT 1,
|
||||
greenMin INTEGER NOT NULL DEFAULT 0,
|
||||
greenMax INTEGER NOT NULL DEFAULT 12,
|
||||
yellowMin INTEGER NOT NULL DEFAULT 13,
|
||||
yellowMax INTEGER NOT NULL DEFAULT 36,
|
||||
redMin INTEGER NOT NULL DEFAULT 37,
|
||||
processCompleteBands TEXT NOT NULL DEFAULT '[]',
|
||||
createdAt INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scoring_profile_questionnaire (
|
||||
profileID TEXT NOT NULL,
|
||||
questionnaireID TEXT NOT NULL,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (profileID, questionnaireID),
|
||||
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_scoring_profile_result (
|
||||
clientCode TEXT NOT NULL,
|
||||
profileID TEXT NOT NULL,
|
||||
weightedTotal REAL NOT NULL DEFAULT 0,
|
||||
band TEXT NOT NULL DEFAULT '',
|
||||
computedAt INTEGER NOT NULL DEFAULT 0,
|
||||
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
|
||||
coachBand TEXT NOT NULL DEFAULT '',
|
||||
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
|
||||
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (clientCode, profileID),
|
||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
|
||||
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile
|
||||
ON client_scoring_profile_result(profileID);
|
||||
|
||||
|
||||
237
scripts/generate_dev_fixture.py
Normal file
237
scripts/generate_dev_fixture.py
Normal file
@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate dev-test-fixture.json for admin Dev tab import."""
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
PREFIX = "dev_"
|
||||
PASSWORD = "socialvrlab"
|
||||
CLIENT_PREFIX = "DEV-CL-"
|
||||
SCALE = 5
|
||||
|
||||
# Fixed RNG + anchor time so the file is reproducible but not grid-like.
|
||||
RNG = random.Random(42)
|
||||
ANCHOR = datetime(2026, 5, 27, 15, 30, 0)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
FALLBACK_QUESTIONNAIRE_IDS = [
|
||||
"questionnaire_1_demographic_information",
|
||||
"questionnaire_2_rhs",
|
||||
"questionnaire_3_integration_index",
|
||||
"questionnaire_4_consultation_results",
|
||||
"questionnaire_5_final_interview",
|
||||
"questionnaire_6_follow_up_survey",
|
||||
]
|
||||
|
||||
NUM_ADMINS = 2 * SCALE
|
||||
NUM_SUPERVISORS = 3 * SCALE
|
||||
NUM_COACHES = 20 * SCALE
|
||||
NUM_CLIENTS = 100 * SCALE
|
||||
CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE
|
||||
|
||||
# Roughly how many clients (with a coach) get at least one questionnaire.
|
||||
COMPLETION_PARTICIPATION = 0.82
|
||||
|
||||
|
||||
def resolve_bundle_path() -> Path | None:
|
||||
"""Use the newest questionnaires_bundle_*.json in the repo root."""
|
||||
candidates = sorted(
|
||||
REPO_ROOT.glob("questionnaires_bundle_*.json"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def load_questionnaire_ids(bundle_path: Path | None) -> list[str]:
|
||||
if bundle_path is None or not bundle_path.is_file():
|
||||
return FALLBACK_QUESTIONNAIRE_IDS
|
||||
bundle = json.loads(bundle_path.read_text(encoding="utf-8"))
|
||||
ids = [
|
||||
item["questionnaire"]["questionnaireID"]
|
||||
for item in bundle.get("questionnaires", [])
|
||||
if item.get("questionnaire", {}).get("questionnaireID")
|
||||
]
|
||||
return ids or FALLBACK_QUESTIONNAIRE_IDS
|
||||
|
||||
|
||||
def ts_days_ago(min_days: float, max_days: float) -> int:
|
||||
days = RNG.uniform(min_days, max_days)
|
||||
return int((ANCHOR - timedelta(days=days)).timestamp())
|
||||
|
||||
|
||||
def variant_seed(client_code: str, qn_id: str, version_index: int) -> int:
|
||||
raw = f"{client_code}:{qn_id}:v{version_index}"
|
||||
return int.from_bytes(raw.encode(), "big") % 100_000
|
||||
|
||||
|
||||
def assign_coaches(clients: list[dict], coaches: list[dict]) -> None:
|
||||
"""Uneven coach load: a few busy coaches, several light ones."""
|
||||
coach_names = [c["username"] for c in coaches]
|
||||
weights = []
|
||||
for i, _ in enumerate(coach_names):
|
||||
# Coaches 1–4 and 7, 12 get more clients; tail is lighter.
|
||||
base = RNG.uniform(0.4, 1.0)
|
||||
if (i % 7) in (0, 1, 2):
|
||||
base *= RNG.uniform(2.0, 4.5)
|
||||
if i % 11 == 0:
|
||||
base *= RNG.uniform(0.15, 0.45)
|
||||
weights.append(base)
|
||||
|
||||
for row in clients:
|
||||
row["coach"] = RNG.choices(coach_names, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
def build_completions(
|
||||
questionnaire_ids: list[str],
|
||||
clients_with_data: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Natural-ish completion patterns:
|
||||
- Sequential questionnaire funnel (not random qn per slot)
|
||||
- Irregular timestamps and gaps between uploads
|
||||
- ~20% of client/qn pairs have 2–4 archived versions
|
||||
"""
|
||||
completions: list[dict] = []
|
||||
qn_count = len(questionnaire_ids)
|
||||
|
||||
# How many questionnaires a client completes (skewed toward mid funnel).
|
||||
qn_count_weights = [8, 14, 22, 24, 18, 10, 4][:qn_count]
|
||||
if len(qn_count_weights) < qn_count:
|
||||
qn_count_weights += [2] * (qn_count - len(qn_count_weights))
|
||||
|
||||
for client_code in clients_with_data:
|
||||
if RNG.random() > COMPLETION_PARTICIPATION:
|
||||
continue
|
||||
|
||||
num_qn = RNG.choices(
|
||||
list(range(1, qn_count + 1)),
|
||||
weights=qn_count_weights[:qn_count],
|
||||
k=1,
|
||||
)[0]
|
||||
selected = questionnaire_ids[:num_qn]
|
||||
|
||||
# First activity somewhere in the last ~6 months; some clients are very recent.
|
||||
cursor = ts_days_ago(3, 185)
|
||||
if RNG.random() < 0.12:
|
||||
cursor = ts_days_ago(0.5, 14)
|
||||
|
||||
for qn_id in selected:
|
||||
version_count = 1
|
||||
roll = RNG.random()
|
||||
if roll < 0.08 and num_qn >= 2:
|
||||
version_count = 4
|
||||
elif roll < 0.20:
|
||||
version_count = 3
|
||||
elif roll < 0.38:
|
||||
version_count = 2
|
||||
|
||||
uploads = []
|
||||
for v in range(version_count):
|
||||
if v > 0:
|
||||
# Re-upload after days or weeks (sometimes same day correction).
|
||||
if RNG.random() < 0.18:
|
||||
gap_sec = RNG.randint(2 * 3600, 36 * 3600)
|
||||
else:
|
||||
gap_sec = RNG.randint(2 * 86400, 55 * 86400)
|
||||
cursor += gap_sec
|
||||
else:
|
||||
# First upload for this qn: often a few days after previous qn.
|
||||
cursor += RNG.randint(0, 12 * 86400)
|
||||
|
||||
duration = RNG.randint(4 * 60, 55 * 60)
|
||||
started = cursor - duration
|
||||
completed = cursor + RNG.randint(0, 120)
|
||||
|
||||
uploads.append({
|
||||
"submittedAt": completed,
|
||||
"startedAt": started,
|
||||
"variant": variant_seed(client_code, qn_id, v),
|
||||
})
|
||||
|
||||
completions.append({
|
||||
"clientCode": client_code,
|
||||
"questionnaireID": qn_id,
|
||||
"versions": version_count,
|
||||
"uploads": uploads,
|
||||
})
|
||||
|
||||
RNG.shuffle(completions)
|
||||
return completions
|
||||
|
||||
|
||||
def main():
|
||||
bundle_path = resolve_bundle_path()
|
||||
questionnaire_ids = load_questionnaire_ids(bundle_path)
|
||||
|
||||
admins = [
|
||||
{"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"}
|
||||
for i in range(1, NUM_ADMINS + 1)
|
||||
]
|
||||
supervisors = [
|
||||
{"username": f"{PREFIX}supervisor_{i}", "location": f"Dev Supervisor Region {i}"}
|
||||
for i in range(1, NUM_SUPERVISORS + 1)
|
||||
]
|
||||
coaches = []
|
||||
for i in range(1, NUM_COACHES + 1):
|
||||
sv = (i - 1) % NUM_SUPERVISORS + 1
|
||||
coaches.append({
|
||||
"username": f"{PREFIX}coach_{i:02d}",
|
||||
"supervisor": f"{PREFIX}supervisor_{sv}",
|
||||
})
|
||||
|
||||
clients = [
|
||||
{"clientCode": f"{CLIENT_PREFIX}{i:04d}"}
|
||||
for i in range(1, NUM_CLIENTS + 1)
|
||||
]
|
||||
assign_coaches(clients, coaches)
|
||||
|
||||
all_codes = [c["clientCode"] for c in clients]
|
||||
no_data_codes = set(RNG.sample(all_codes, min(CLIENTS_WITHOUT_COMPLETIONS, len(all_codes))))
|
||||
clients_with_data = [c for c in all_codes if c not in no_data_codes]
|
||||
|
||||
completions = build_completions(questionnaire_ids, clients_with_data)
|
||||
total_uploads = sum(c["versions"] for c in completions)
|
||||
multi_version_pairs = sum(1 for c in completions if c["versions"] > 1)
|
||||
|
||||
bundle_note = bundle_path.name if bundle_path and bundle_path.is_file() else "fallback questionnaire list"
|
||||
|
||||
fixture = {
|
||||
"fixtureVersion": 2,
|
||||
"description": (
|
||||
f"Dev/test users, clients, completions with uneven coach load and upload history "
|
||||
f"({SCALE}x scale). Questionnaires from {bundle_note}. Password: socialvrlab."
|
||||
),
|
||||
"prefix": PREFIX,
|
||||
"defaultPassword": PASSWORD,
|
||||
"sourceBundle": bundle_note,
|
||||
"questionnaireIDs": questionnaire_ids,
|
||||
"admins": admins,
|
||||
"supervisors": supervisors,
|
||||
"coaches": coaches,
|
||||
"clients": clients,
|
||||
"completions": completions,
|
||||
"stats": {
|
||||
"scale": SCALE,
|
||||
"admins": len(admins),
|
||||
"supervisors": len(supervisors),
|
||||
"coaches": len(coaches),
|
||||
"clients": len(clients),
|
||||
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
|
||||
"completionRecords": len(completions),
|
||||
"totalUploads": total_uploads,
|
||||
"multiVersionPairs": multi_version_pairs,
|
||||
"questionnaires": len(questionnaire_ids),
|
||||
},
|
||||
}
|
||||
|
||||
out = REPO_ROOT / "dev-test-fixture.json"
|
||||
out.write_text(json.dumps(fixture, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {out}")
|
||||
print(json.dumps(fixture["stats"], indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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);
|
||||
}
|
||||
22
tests/Integration/ApiRoutingTest.php
Normal file
22
tests/Integration/ApiRoutingTest.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ApiRoutingTest extends QdbTestCase
|
||||
{
|
||||
public function testUnknownRoute404(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'does-not-exist');
|
||||
$this->assertApiError($res, 'NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWithoutToken(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'questionnaires');
|
||||
$this->assertApiError($res, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
}
|
||||
51
tests/Integration/AppQuestionnairesBulkTest.php
Normal file
51
tests/Integration/AppQuestionnairesBulkTest.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppQuestionnairesBulkTest extends QdbTestCase
|
||||
{
|
||||
public function testCoachLoadsBulkAnswers(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'answersBulk' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['encrypted'] ?? false);
|
||||
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
|
||||
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertIsArray($payload['clients'] ?? null);
|
||||
$found = null;
|
||||
foreach ($payload['clients'] as $row) {
|
||||
if (($row['clientCode'] ?? '') === $this->fixture()->clientCode) {
|
||||
$found = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($found, 'fixture client missing from bulk payload');
|
||||
$this->assertNotEmpty($found['questionnaires'] ?? []);
|
||||
$qn = $found['questionnaires'][0];
|
||||
$this->assertSame($this->fixture()->questionnaireId, $qn['questionnaireID'] ?? null);
|
||||
$this->assertNotEmpty($qn['answers'] ?? []);
|
||||
}
|
||||
|
||||
public function testReadDbOpenUsesCacheOnSecondCall(): void
|
||||
{
|
||||
[$pdo1, $tmp1] = qdb_open(false);
|
||||
[$pdo2, $tmp2] = qdb_open(false);
|
||||
$this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp1);
|
||||
$this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp2);
|
||||
$this->assertSame($pdo1, $pdo2);
|
||||
qdb_discard($tmp1, null);
|
||||
qdb_discard($tmp2, null);
|
||||
}
|
||||
}
|
||||
99
tests/Integration/AppQuestionnairesTest.php
Normal file
99
tests/Integration/AppQuestionnairesTest.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppQuestionnairesTest extends QdbTestCase
|
||||
{
|
||||
public function testCoachSubmitsQuestionnaire(): void
|
||||
{
|
||||
$res = $this->submitFixtureQuestionnaire();
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['submitted'] ?? false);
|
||||
}
|
||||
|
||||
public function testCoachLoadsQuestionnaireDefinition(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'id' => $this->fixture()->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertNotEmpty($res['data']['questions']);
|
||||
|
||||
$glass = null;
|
||||
foreach ($res['data']['questions'] as $q) {
|
||||
if (($q['layout'] ?? '') === 'glass_scale_question') {
|
||||
$glass = $q;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($glass);
|
||||
$this->assertIsArray($glass['glassSymptoms'] ?? null);
|
||||
$symptomKeys = array_column($glass['glassSymptoms'], 'key');
|
||||
$this->assertContains($this->fixture()->glassSymptomKey, $symptomKeys);
|
||||
}
|
||||
|
||||
public function testCoachListsAssignedClients(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'clients' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['encrypted'] ?? false);
|
||||
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
|
||||
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$codes = array_column($payload['clients'], 'clientCode');
|
||||
$this->assertContains($this->fixture()->clientCode, $codes);
|
||||
}
|
||||
|
||||
public function testCoachClientNoteRoundTrip(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$code = $this->fixture()->clientCode;
|
||||
$note = 'Bitte Rückruf nächste Woche';
|
||||
|
||||
$save = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [
|
||||
'action' => 'setClientNote',
|
||||
'clientCode' => $code,
|
||||
'note' => $note,
|
||||
]);
|
||||
$this->assertApiOk($save);
|
||||
$this->assertTrue($save['data']['encrypted'] ?? false);
|
||||
$savedPlain = qdb_decrypt_sensitive_envelope($save['data'], $login['token']);
|
||||
$savedPayload = json_decode($savedPlain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($note, $savedPayload['note']);
|
||||
|
||||
$list = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'clients' => '1',
|
||||
]);
|
||||
$this->assertApiOk($list);
|
||||
$listPlain = qdb_decrypt_sensitive_envelope($list['data'], $login['token']);
|
||||
$listPayload = json_decode($listPlain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$match = null;
|
||||
foreach ($listPayload['clients'] as $row) {
|
||||
if (($row['clientCode'] ?? '') === $code) {
|
||||
$match = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertIsArray($match);
|
||||
$this->assertSame($note, $match['note'] ?? null);
|
||||
}
|
||||
}
|
||||
271
tests/Integration/AppSubmitExtendedTest.php
Normal file
271
tests/Integration/AppSubmitExtendedTest.php
Normal file
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppSubmitExtendedTest extends QdbTestCase
|
||||
{
|
||||
public function testResubmitClearsOmittedOptionalAnswers(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Old notes'],
|
||||
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
|
||||
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
|
||||
]));
|
||||
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$ft = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]);
|
||||
$this->assertFalse($ft->fetchColumn());
|
||||
|
||||
$glass = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
|
||||
$this->assertFalse($glass->fetchColumn());
|
||||
|
||||
$sub = $pdo->prepare(
|
||||
'SELECT submissionID FROM questionnaire_submission
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
|
||||
);
|
||||
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
|
||||
$submissionId = (string)$sub->fetchColumn();
|
||||
|
||||
$archived = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer_submission
|
||||
WHERE submissionID = :sid AND questionID = :qid'
|
||||
);
|
||||
$archived->execute([':sid' => $submissionId, ':qid' => $f->freeTextQuestionId]);
|
||||
$this->assertFalse($archived->fetchColumn());
|
||||
|
||||
$archivedGlass = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer_submission
|
||||
WHERE submissionID = :sid AND questionID = :qid'
|
||||
);
|
||||
$archivedGlass->execute([':sid' => $submissionId, ':qid' => $f->glassQuestionId]);
|
||||
$this->assertFalse($archivedGlass->fetchColumn());
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testResubmitReplacesGlassSymptomsInsteadOfMerging(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
|
||||
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
|
||||
]));
|
||||
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'mild'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$glass = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
|
||||
$decoded = json_decode((string)$glass->fetchColumn(), true);
|
||||
$this->assertSame(['pain' => 'mild'], $decoded);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testResubmitCreatesSecondSubmissionVersion(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->assertApiOk($this->submitFixtureQuestionnaire());
|
||||
$this->assertSame(1, $this->submissionVersionCount($f->clientCode, $f->questionnaireId));
|
||||
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['submitted'] ?? false);
|
||||
$this->assertSame(2, $this->submissionVersionCount($f->clientCode, $f->questionnaireId));
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM client_answer_submission cas
|
||||
INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID
|
||||
WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn'
|
||||
);
|
||||
$stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
|
||||
$this->assertGreaterThanOrEqual(2, (int)$stmt->fetchColumn());
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testSupervisorCanSubmitForClient(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT submittedByRole FROM questionnaire_submission
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
|
||||
$this->assertSame('supervisor', $stmt->fetchColumn());
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testSubmitWithFreeTextAndGlassSymptom(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Feeling better'],
|
||||
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$ft = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]);
|
||||
$this->assertSame('Feeling better', $ft->fetchColumn());
|
||||
|
||||
$glass = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
|
||||
$json = (string)$glass->fetchColumn();
|
||||
$decoded = json_decode($json, true);
|
||||
$this->assertIsArray($decoded);
|
||||
$this->assertSame('moderate', $decoded[$f->glassSymptomKey] ?? null);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testCoachLoadsEncryptedAnswersBundle(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'clientCode' => $f->clientCode,
|
||||
'answers' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['encrypted'] ?? false);
|
||||
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
|
||||
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($f->clientCode, $payload['clientCode']);
|
||||
$this->assertNotEmpty($payload['questionnaires']);
|
||||
}
|
||||
|
||||
public function testMultiCheckSubmitAccumulatesPoints(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_a'],
|
||||
['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_b'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame(3, $res['data']['sumPoints'] ?? 0);
|
||||
}
|
||||
|
||||
public function testRequiredQuestionMissingFailsValidation(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'skipped consent'],
|
||||
]);
|
||||
$this->assertApiError($res, 'VALIDATION_FAILED', 400);
|
||||
$codes = array_column($res['error']['details']['errors'] ?? [], 'code');
|
||||
$this->assertContains('MISSING_ANSWER', $codes);
|
||||
}
|
||||
|
||||
public function testActiveListExcludesDraftQuestionnaire(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires');
|
||||
$this->assertApiOk($res);
|
||||
$ids = array_column($res['data'], 'id');
|
||||
$this->assertContains($f->questionnaireId, $ids);
|
||||
$this->assertNotContains($f->draftQuestionnaireId, $ids);
|
||||
}
|
||||
|
||||
public function testTempTokenCannotSubmitQuestionnaire(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$f = $this->fixture();
|
||||
$pdo->prepare('UPDATE users SET mustChangePassword = 1 WHERE userID = :uid')
|
||||
->execute([':uid' => $f->supervisorUserId]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$login = $this->api()->loginMobile(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$this->assertApiOk($login);
|
||||
$tempToken = $login['data']['token'];
|
||||
$res = $this->api()->encryptedPost($tempToken, 'app_questionnaires', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'clientCode' => $f->clientCode,
|
||||
'answers' => [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
],
|
||||
]);
|
||||
$this->assertApiError($res, 'PASSWORD_CHANGE_REQUIRED', 403);
|
||||
}
|
||||
}
|
||||
78
tests/Integration/AppSubmitLegacyTest.php
Normal file
78
tests/Integration/AppSubmitLegacyTest.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppSubmitLegacyTest extends QdbTestCase
|
||||
{
|
||||
public function testLegacySubmitAcceptsRetiredQuestionAfterBump(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->assertApiOk($this->submitQuestionnaireAs(
|
||||
$login['token'],
|
||||
$f->questionnaireId,
|
||||
$f->clientCode,
|
||||
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
|
||||
null,
|
||||
null,
|
||||
1
|
||||
));
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'legacy_test_retire');
|
||||
qdb_retire_question($pdo, $f->questionId, $rev);
|
||||
$currentRev = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$this->assertGreaterThan(1, $currentRev);
|
||||
|
||||
$res = $this->submitQuestionnaireAs(
|
||||
$login['token'],
|
||||
$f->questionnaireId,
|
||||
$f->clientCode,
|
||||
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
|
||||
null,
|
||||
null,
|
||||
1
|
||||
);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['submitted'] ?? false);
|
||||
$this->assertTrue($res['data']['legacySubmit'] ?? false);
|
||||
$this->assertSame(1, $res['data']['structureRevision'] ?? null);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$sub = $pdo->prepare(
|
||||
'SELECT structureRevision, structureSnapshotJson FROM questionnaire_submission
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
|
||||
);
|
||||
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
|
||||
$row = $sub->fetch(\PDO::FETCH_ASSOC);
|
||||
$this->assertSame(1, (int)($row['structureRevision'] ?? 0));
|
||||
$snap = json_decode($row['structureSnapshotJson'] ?? '{}', true) ?: [];
|
||||
$this->assertNotEmpty($snap['questions'] ?? []);
|
||||
|
||||
$archived = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM client_answer_submission cas
|
||||
INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID
|
||||
WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn AND cas.questionID = :qid'
|
||||
);
|
||||
$archived->execute([
|
||||
':cc' => $f->clientCode,
|
||||
':qn' => $f->questionnaireId,
|
||||
':qid' => $f->questionId,
|
||||
]);
|
||||
$this->assertGreaterThan(0, (int)$archived->fetchColumn());
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
38
tests/Integration/AssignmentsActivityLogTest.php
Normal file
38
tests/Integration/AssignmentsActivityLogTest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AssignmentsActivityLogTest extends QdbTestCase
|
||||
{
|
||||
public function testAssignmentsListForAdmin(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'assignments');
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('coaches', $res['data']);
|
||||
$this->assertArrayHasKey('clients', $res['data']);
|
||||
}
|
||||
|
||||
public function testActivityLogReadableByAdmin(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$today = date('Y-m-d');
|
||||
$res = $this->api()->withToken($token, 'GET', 'activity-log', null, [
|
||||
'date' => $today,
|
||||
'limit' => '50',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
}
|
||||
68
tests/Integration/AuthTest.php
Normal file
68
tests/Integration/AuthTest.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AuthTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminLoginSuccess(): void
|
||||
{
|
||||
$res = $this->api()->loginWeb(DatabaseSeeder::ADMIN_USERNAME, DatabaseSeeder::PASSWORD);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertNotEmpty($res['data']['token']);
|
||||
$this->assertSame('admin', $res['data']['role']);
|
||||
}
|
||||
|
||||
public function testInvalidCredentials(): void
|
||||
{
|
||||
$res = $this->api()->loginWeb(DatabaseSeeder::ADMIN_USERNAME, 'wrong');
|
||||
$this->assertApiError($res, 'INVALID_CREDENTIALS', 401);
|
||||
}
|
||||
|
||||
public function testCoachBlockedOnWebClient(): void
|
||||
{
|
||||
$res = $this->api()->loginWeb(DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testRateLimitAfterFailures(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
qdb_settings_save_on_pdo($pdo, qdb_settings_validate_and_merge([
|
||||
'login_max_attempts' => 2,
|
||||
'login_window_seconds' => 600,
|
||||
'login_lockout_seconds' => 600,
|
||||
], qdb_settings_get_on_pdo($pdo)));
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$this->api()->loginWeb('rate_user', 'bad1');
|
||||
$this->api()->loginWeb('rate_user', 'bad2');
|
||||
$res = $this->api()->loginWeb('rate_user', 'bad3');
|
||||
$this->assertApiError($res, 'RATE_LIMITED', 429);
|
||||
}
|
||||
|
||||
public function testMissingFields(): void
|
||||
{
|
||||
$res = $this->api()->request('POST', 'auth/login', ['username' => '']);
|
||||
$this->assertApiError($res, 'MISSING_FIELDS', 400);
|
||||
}
|
||||
|
||||
public function testKeycloakConfigDisabledByDefault(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'auth/keycloak-config');
|
||||
$this->assertApiOk($res);
|
||||
$this->assertFalse($res['data']['enabled']);
|
||||
$this->assertSame('', $res['data']['loginUrl']);
|
||||
}
|
||||
|
||||
public function testKeycloakLoginRequiresConfiguration(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'auth/keycloak-login');
|
||||
$this->assertApiError($res, 'KEYCLOAK_NOT_CONFIGURED', 503);
|
||||
}
|
||||
}
|
||||
80
tests/Integration/BackupDevOpsTest.php
Normal file
80
tests/Integration/BackupDevOpsTest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class BackupDevOpsTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminBackupCreatesCopyOfDatabase(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'backup');
|
||||
$this->assertApiOk($res);
|
||||
$filename = $res['data']['filename'] ?? '';
|
||||
$this->assertNotSame('', $filename);
|
||||
|
||||
$path = QDB_UPLOADS_DIR . '/backups/' . $filename;
|
||||
$this->assertFileExists($path);
|
||||
$this->assertGreaterThan(0, filesize($path));
|
||||
$this->assertSame(filesize(QDB_PATH), filesize($path));
|
||||
}
|
||||
|
||||
public function testDevImportEmptyFixtureSucceeds(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'dev', [
|
||||
'fixture' => [
|
||||
'fixtureVersion' => 2,
|
||||
'prefix' => 'dev_',
|
||||
'defaultPassword' => 'DevPass1!',
|
||||
'admins' => [],
|
||||
'supervisors' => [],
|
||||
'coaches' => [],
|
||||
'clients' => [],
|
||||
],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame(0, $res['data']['imported']['clients'] ?? -1);
|
||||
}
|
||||
|
||||
public function testDevRejectsInvalidFixtureVersion(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'dev', [
|
||||
'fixture' => [
|
||||
'fixtureVersion' => 99,
|
||||
'prefix' => 'dev_',
|
||||
'defaultPassword' => 'DevPass1!',
|
||||
],
|
||||
]);
|
||||
$this->assertApiError($res, 'INVALID_FIELD', 400);
|
||||
}
|
||||
|
||||
public function testMigrationAddsCategoryKeyColumn(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
|
||||
$this->markTestSkipped('categoryKey already absent in fresh schema');
|
||||
}
|
||||
$pdo->exec('ALTER TABLE questionnaire DROP COLUMN categoryKey');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$this->assertTrue(qdb_column_exists($pdo2, 'questionnaire', 'categoryKey'));
|
||||
qdb_discard($tmp2, $lock2);
|
||||
}
|
||||
|
||||
}
|
||||
94
tests/Integration/CatalogApiTest.php
Normal file
94
tests/Integration/CatalogApiTest.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class CatalogApiTest extends QdbTestCase
|
||||
{
|
||||
public function testListLanguages(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['languages' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertIsArray($res['data']['languages']);
|
||||
}
|
||||
|
||||
public function testListQuestionsForQuestionnaire(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withToken($token, 'GET', 'questions', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$ids = array_column($res['data']['questions'], 'questionID');
|
||||
$this->assertContains($f->questionId, $ids);
|
||||
}
|
||||
|
||||
public function testListAnswerOptions(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'answer_options', null, [
|
||||
'questionID' => $this->fixture()->questionId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertNotEmpty($res['data']['answerOptions']);
|
||||
}
|
||||
|
||||
public function testListCoachesForSupervisor(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'coaches');
|
||||
$this->assertApiOk($res);
|
||||
$coachIds = array_column($res['data']['coaches'], 'coachID');
|
||||
$this->assertContains($this->fixture()->coachId, $coachIds);
|
||||
}
|
||||
|
||||
public function testTranslationsAllDashboard(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['all' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('questionnaires', $res['data']);
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
|
||||
public function testTranslationsForQuestionnaire(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, [
|
||||
'questionnaireID' => $this->fixture()->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
|
||||
public function testAppTranslationsPublicNoAuth(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('translations', $res['data']);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user