Compare commits

112 Commits

Author SHA1 Message Date
90bfd0bbd3 expanded questionnaire dependency matrix
Some checks are pending
PHPUnit / test (push) Waiting to run
2026-07-17 17:57:36 +02:00
764b4c5545 settings for future and past dates
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:45:38 +02:00
9265181154 cleaner note edit form
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:37:32 +02:00
497246cd57 editable client note on website
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:36:10 +02:00
4d7be68080 show full text instead
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:35:00 +02:00
fc23f7a619 display client notes in client list
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:33:48 +02:00
f90d5db40a adjust color palette
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:21:48 +02:00
6b3cd0da17 adding client notes
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-17 17:10:04 +02:00
feae470fba minor fix
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-10 10:01:42 +02:00
810ed24792 migration to mysql
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-10 09:57:44 +02:00
351af170a0 reverse client reset functionality
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-09 16:39:00 +02:00
f04388e0ec changes to translation system
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-09 14:59:56 +02:00
4afab336ee bulk client creation
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-07 16:53:35 +02:00
181ca2b7d4 added some ui strings for translations
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-01 12:33:55 +02:00
339091e13f adding process completion logic
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-07-01 12:07:41 +02:00
9bd9d9653c preparing for keycloak authentication
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-29 11:31:53 +02:00
cecaf26c85 added new UI string for displaying the questionnaire
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-24 12:31:03 +02:00
547ed8454e added new UI string
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-24 12:12:04 +02:00
7232fc5a17 updated app ui strings
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-24 12:08:22 +02:00
67858d34d9 enhanced token management by adding optional expiry refresh in token_get_record and updated session API documentation
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-24 10:23:19 +02:00
b02f264b23 improved security
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-24 09:37:08 +02:00
623fe0f464 hide permanent password change option
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-17 11:29:33 +02:00
19f9faeb2d some more strings
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-16 13:03:07 +02:00
53a1dfac2a added new strings for logout logic
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-16 12:46:14 +02:00
0add7c62e9 adjusted to more secure nginx config
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-16 10:49:20 +02:00
3d738bb14e updated terminology from "Coach" to "Counselor" across multiple files for consistency and clarity
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-15 16:04:44 +02:00
5dd0ee28c0 switched to light mode
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-15 10:18:51 +02:00
b06c4bf3ee refactored confirmation dialogs to use a unified confirmAction modal
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-14 18:02:03 +02:00
6ae29f1648 minor styling changes
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-14 17:56:17 +02:00
71956c0927 refactored qdb_read_materialize_decrypted_db to handle SQLite opening errors
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 22:30:36 +02:00
ebf27811db implemented qdb_memfd_fill_fd function to enhance file descriptor writing using FFI
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 22:24:27 +02:00
560ca69ae8 refactored memfd handling to use FFI for Linux, added qdb_close_fd function
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 22:14:51 +02:00
b4d4de72f4 enhanced questionnaire version handling and handling uploads from outdated questionnaires through automatic revisioning and checks
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 21:38:39 +02:00
5dff24a232 added functionality to archive clients
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 20:55:12 +02:00
59ea2cd667 improved assign clients page ux
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 20:43:18 +02:00
cceec525c1 improved translation page UX
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 20:33:52 +02:00
d807f753b1 switched first completed to last completed on needs followup table
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 20:20:56 +02:00
5b5cc52133 Updated UI strings and references from "coach" to "counselor"
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 13:41:42 +02:00
63a560e803 added glass scale symptoms to translations
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-12 13:07:49 +02:00
fadf60b2f7 fixed minor bug
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-08 20:00:21 +02:00
b54a33c78d added missing dependency
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-08 19:56:08 +02:00
d16bd1b3c4 test data import fix for scoring
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-08 19:44:58 +02:00
3211d35b43 improved scoring ui + removed test data filters
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-08 19:38:37 +02:00
11bae269e9 added website overwrite for score decision
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-08 19:22:12 +02:00
63adef79df implemented scoring system
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-08 19:10:23 +02:00
4589be119c Add read-only client and coach code fields in questionnaire preview
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-06 16:40:19 +02:00
8fdaefe10b Remove 'client_not_signed' layout from questionnaire logic
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-06 16:35:57 +02:00
e5ae48c02e improved questionnaire editor glass scales
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-06 16:28:10 +02:00
d68b2b267d added preview window in questionnaire editor 2026-06-06 16:23:32 +02:00
91834d0b16 Add support for app string translations in questionnaire imports, enhance UI strings, and improve question navigation in editor
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-06 16:07:06 +02:00
c37940c9a9 enhanced submission handling with deccision trees
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-05 11:57:28 +02:00
6883654d7c Implement bulk export of client answers for coaches and enhance database handling in db_init.php
Some checks failed
PHPUnit / test (push) Has been cancelled
2026-06-04 22:05:36 +02:00
48a619ee4b added unit tests 2026-06-04 21:40:59 +02:00
d80a8de559 added session revocation and settings menu for security measures 2026-06-03 23:10:26 +02:00
ffe6d04d75 Refactor activity log UI 2026-06-03 17:37:28 +02:00
fc84d55bd6 further enhanced error handling + added retries in case of parallel writes 2026-06-03 17:27:51 +02:00
d0daa7e937 Refactor error handling and logging in API responses 2026-06-03 17:16:14 +02:00
30d3ab4a87 added IP to log activity UI 2026-06-01 23:15:43 +02:00
43c47bca6d Add session management features: implement user session revocation on password change, enhance session validation, and update UI for password change functionality 2026-06-01 23:11:00 +02:00
e4d55f4c90 potential fix for logging on server 2026-06-01 22:50:22 +02:00
e5ac069dc4 expanded log activity 2026-06-01 22:46:48 +02:00
da394c5525 implement activity logging feature with UI for viewing logs 2026-06-01 21:40:50 +02:00
890232f58f added password reset 2026-06-01 21:23:33 +02:00
82a1614a58 refactor dotenv parsing to improve caching logic and ensure readability; update health endpoint to reflect key match status 2026-06-01 19:11:12 +02:00
6895aa5674 improved debugging + health endpoint 2026-06-01 19:09:27 +02:00
a5e58e88c1 refactor environment variable handling to prioritize .env values over PHP-FPM and improve dotenv loading logic 2026-06-01 19:05:33 +02:00
6174d3c820 added functions for environment variable management and updated dotenv loading logic 2026-06-01 19:00:18 +02:00
62c70581d9 appended schema.sql to git 2026-06-01 16:42:07 +02:00
3d449a5672 enhanced debug 2026-06-01 15:02:20 +02:00
d6cbe5fbe5 enhanced debug script for website 2026-06-01 14:34:58 +02:00
7ff2347b74 add debug logic 2026-06-01 14:28:01 +02:00
6c17166558 enhanced navigation and added coach reassignment 2026-06-01 14:11:06 +02:00
9ed66ca327 good client and coach data viewing 2026-06-01 13:49:56 +02:00
3d56abd124 made client data browsable with dropdown and version diffs 2026-06-01 13:29:10 +02:00
bee7b74e53 added submission history, data insights, download all functionality 2026-06-01 12:54:56 +02:00
9a6fa22d84 improved condition json to have UI integration 2026-05-28 12:26:45 +02:00
9cf67e765f improved UX in dashboard 2026-05-28 11:29:14 +02:00
b049a9ab9f better ux with search functionality 2026-05-28 10:58:31 +02:00
8915c8d182 added completion count to export page 2026-05-28 10:09:17 +02:00
5d539c130f added qdb_normalize_stable_key function to create stable identifiers from arbitrary key strings 2026-05-27 20:13:43 +02:00
24080bb8b9 implemented incremental schema migration for questionnaire, added categoryKey column if missing, and improved error handling in questionnaire retrieval 2026-05-27 20:03:47 +02:00
36b6d63266 added categoryKey to questionnaire schema and updated related functions for questionnaire handling; enhanced API to fetch client answers 2026-05-27 19:38:49 +02:00
9c887537e8 removed legacy files 2026-05-27 18:32:00 +02:00
0f98393d1d make translation api public to fetch on first app start 2026-05-27 17:43:17 +02:00
5a2faa3ecc refactored translation mapping logic to streamline default handling and restricted option assignment based on layout type 2026-05-27 15:15:39 +02:00
e8f92cc6ca enhanced translation handling by ensuring source language inclusion and exposing German defaults for untranslated keys 2026-05-27 14:16:01 +02:00
d96d935614 added translation for incomplete questionnaire notification in English and German 2026-05-27 14:08:24 +02:00
0f89ed6d6f added translations export and import 2026-05-27 10:53:05 +02:00
9a75577f14 fixing db wipe function order 2026-05-27 10:46:49 +02:00
5da345246b renamed "example_text" to "questionnaire_title" 2026-05-27 10:35:40 +02:00
7f8cc4a632 refined toast component styles to adjust for new color scheme 2026-05-27 10:12:46 +02:00
1d9154e664 updated color scheme to dark mode 2026-05-27 10:06:39 +02:00
ff370d0d39 UX improvements in data views 2026-05-27 09:58:08 +02:00
25851c4b86 showing quesiton-keys instead of ids 2026-05-26 17:13:32 +02:00
ea3a423186 UX enhancements on data views 2026-05-26 17:07:18 +02:00
9d783b60a9 added prototype dev settings with test import and db wipe 2026-05-26 16:38:37 +02:00
2181d1731e revoked access to website for coaches, added web-specific tokens 2026-05-26 15:49:19 +02:00
7671c9f329 enhanced questionnaire handling with stable key validation, improved note management, and added option key support 2026-05-26 15:40:35 +02:00
1b3d74d822 added scaleType configuration to questionnaire import and display 2026-05-26 14:15:37 +02:00
8e56a42f7e added support for date specifictiy 2026-05-26 14:01:38 +02:00
aefb1b6440 added free text input with security measures 2026-05-26 13:43:44 +02:00
244fd16140 added quesionnaire export and import 2026-05-22 17:36:40 +02:00
be01b85569 fixed translation bug 2026-05-22 17:18:14 +02:00
472e45d730 fixed minor bug 2026-05-22 17:14:20 +02:00
2ded5b6aaf added app translations for better maintainability 2026-05-22 16:48:47 +02:00
0291b8be7a added translation tab 2026-05-22 16:20:00 +02:00
c86768e391 revert 52d4ef42dc
revert updated api
2026-05-22 12:27:12 +00:00
e0be2c501f Revert "refactored export functionality"
This reverts commit 2616238dc8.
2026-05-22 14:23:39 +02:00
2616238dc8 refactored export functionality 2026-05-22 14:17:02 +02:00
52d4ef42dc updated api 2026-05-22 13:26:29 +02:00
c86372af2e added clients list to coach login response 2026-05-22 12:59:57 +02:00
03291862f7 enhanced client results and export functionality with coach and supervisor details, added date filters for results 2026-05-20 11:01:15 +02:00
186 changed files with 32119 additions and 7622 deletions

12
.env.example Normal file
View 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
View 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
View File

@ -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/

View File

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

View File

@ -1,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);

View File

@ -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"]);

View File

@ -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);

View File

@ -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");
}

View File

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

View File

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

View File

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

View File

@ -1,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
]);

View File

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

View File

@ -1,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"]);

View File

@ -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"]);

View File

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

View File

@ -1,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"]);
}

View File

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

182
cli/diagnose_db.php Normal file
View 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
View 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);
}

View 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";

2568
common.php

File diff suppressed because it is too large Load Diff

26
composer.json Normal file
View 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

Binary file not shown.

340
data/app_ui_strings.json Normal file
View 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. 812) 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"
}
}

View 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

View File

@ -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;

View File

@ -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;
}
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');
}
}

View File

@ -1,78 +0,0 @@
<?php
// /var/www/html/db_view.php
require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8');
$tokenRec = require_valid_token();
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }
$enc = file_get_contents($dbPath);
if ($enc === false) { http_response_code(500); echo "Read error"; exit; }
try {
$plain = aes256_cbc_decrypt_bytes($enc, get_master_key_bytes());
} catch (Throwable $e) {
http_response_code(500); echo "Decryption failed"; exit;
}
$tmp = tempnam(sys_get_temp_dir(), 'qdb_');
file_put_contents($tmp, $plain);
try {
$pdo = new PDO("sqlite:$tmp");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$tables = [];
$rs = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
foreach ($rs as $row) $tables[] = $row['name'];
// Tables that should not be shown (auth data)
$hidden = ['users'];
echo "<style>h2{margin:24px 0 8px} table{border-collapse:collapse;width:100%} th,td{border:1px solid #ddd;padding:6px 8px;text-align:left} th{background:#f7f7f7}</style>";
echo "<p><b>Tables:</b> " . htmlspecialchars(implode(', ', array_diff($tables, $hidden))) . "</p>";
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
foreach ($tables as $t) {
if (in_array($t, $hidden, true)) continue;
echo "<h2>" . htmlspecialchars($t) . "</h2>";
$t_esc = preg_replace('/[^A-Za-z0-9_]/', '', $t);
$cols = [];
$cr = $pdo->query("PRAGMA table_info($t_esc)");
foreach ($cr as $c) $cols[] = $c['name'];
$clientScoped = in_array('clientCode', $cols, true);
if ($clientScoped && ($tokenRec['role'] ?? '') !== 'admin') {
$sql = "SELECT t.* FROM $t_esc t JOIN client ON client.clientCode = t.clientCode WHERE $rbacClause";
$stmt = $pdo->prepare($sql);
foreach ($rbacParams as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
} else {
$stmt = $pdo->query("SELECT * FROM $t_esc");
}
echo "<table><thead><tr>";
foreach ($cols as $c) echo "<th>" . htmlspecialchars($c) . "</th>";
echo "</tr></thead><tbody>";
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo "<tr>";
foreach ($cols as $c) {
$val = $row[$c];
if ($val === null) { echo "<td><em>NULL</em></td>"; }
else { echo "<td>" . htmlspecialchars((string)$val) . "</td>"; }
}
echo "</tr>";
}
echo "</tbody></table>";
}
} catch (Throwable $e) {
error_log($e->getMessage());
http_response_code(500); echo "Server error";
} finally {
@unlink($tmp);
}

View File

@ -1,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);
}

View File

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

View File

@ -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.

View File

@ -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
View 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
View 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);
}

View File

@ -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,13 +12,17 @@ case 'GET':
if (!$qID) {
json_error('MISSING_PARAM', 'questionID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
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);
@ -24,50 +30,62 @@ case 'GET':
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);
}
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);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$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;

View File

@ -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,
'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'];
}
// 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[] = [
$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);

View File

@ -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;

View File

@ -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;

View File

@ -1,15 +1,13 @@
<?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);
@ -17,6 +15,61 @@ if (!is_dir($backupDir)) {
}
$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";

View File

@ -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'] ?? '');
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
View 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
View 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);
}

View File

@ -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;

View File

@ -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');
$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;
}
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
$questions = $ctx['questions'];
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
if ($allVersions) {
$rows = qdb_export_all_versions_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 . '_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);
$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));
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);
}
} else {
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) $header[] = $q['defaultText'];
fputcsv($out, $header);
}
fclose($out);

View File

@ -9,5 +9,10 @@ if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
}
try {
token_revoke($token);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Logout');
}
json_success(['loggedOut' => true]);

View File

@ -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("
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,
COUNT(qu.questionID) AS questionCount
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
")->fetchAll(PDO::FETCH_ASSOC);
";
$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]);
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load questionnaires', null, $tmpDb ?? null, $lockFp ?? null);
}
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;

View File

@ -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,26 +12,50 @@ 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']]);
@ -36,23 +63,41 @@ case 'GET':
}
unset($q);
qdb_discard($tmpDb, $lockFp);
json_success(['questions' => $questions]);
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'];
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config,
]]);
} catch (Throwable $e) {
$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);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
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' => $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([
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
'question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'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_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;

View File

@ -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,7 +15,9 @@ 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->execute([':id' => $qnID]);
@ -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);
}
$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);
qdb_discard($tmpDb, $lockFp);
json_success([
"questionnaire" => $questionnaire,
"questions" => $questions,
"clients" => $clients,
'questionnaire' => $questionnaire,
'questions' => $questions,
'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);
}

View 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
View 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
View 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);
}

View File

@ -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 (!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 . '"',
]
);
}
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);
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]);
}
$qnID = $_GET['questionnaireID'] ?? '';
if ($qnID) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
$qStmt = $pdo->prepare("
SELECT q.questionID, q.defaultText, q.configJson
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
");
$qStmt->execute([':id' => $qnID]);
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$entries = [];
$stringKeys = [];
foreach ($dbQuestions as $dbQ) {
$entries[] = ['key' => $dbQ['defaultText'], 'type' => 'question', 'entityId' => $dbQ['questionID']];
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
$aoStmt->execute([':qid' => $dbQ['questionID']]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$entries[] = ['key' => $ao['defaultText'], 'type' => 'answer_option', 'entityId' => $ao['answerOptionID']];
}
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
foreach (['textKey','textKey1','textKey2','hint','hint1','hint2'] as $field) {
if (!empty($config[$field])) $stringKeys[$config[$field]] = true;
}
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
foreach ($config['symptoms'] as $s) $stringKeys[$s] = true;
json_success([
'languages' => $rows,
'questionnaireLanguageDisables' => $disables,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
}
}
foreach (array_keys($stringKeys) as $sk) {
$entries[] = ['key' => $sk, 'type' => 'string', 'entityId' => $sk];
if (!empty($_GET['all'])) {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$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]);
}
$translations = [];
$qnRows = $pdo->query("
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$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'];
}
}
$appEntries = qdb_app_ui_string_entries_for_website($pdo);
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
qdb_attach_translation_texts($appEntries, $appTranslations);
$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'];
}
}
$questionnaires = [];
$allEntries = $appEntries;
$sKeys = array_filter(array_map(fn($e) => $e['type'] === 'string' ? $e['entityId'] : null, $entries));
if ($sKeys) {
$ph = implode(',', array_fill(0, count($sKeys), '?'));
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
$stmt->execute(array_values($sKeys));
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) {
continue;
}
$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 ($entries as &$e) {
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
}
unset($e);
qdb_discard($tmpDb, $lockFp);
json_success(["languages" => $languages, "entries" => $entries]);
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);
}
}
$qnID = trim((string)($_GET['questionnaireID'] ?? ''));
if ($qnID !== '') {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$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]);
}
$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);
}
$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);
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);
}
}
$type = $_GET['type'] ?? '';
@ -99,7 +141,9 @@ case 'GET':
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
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]);
@ -114,9 +158,62 @@ case 'GET':
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
}
}
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]);
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")
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;

View File

@ -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;

View File

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

View File

@ -1,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"]);
}

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
);
}

View 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
View 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;
}

View File

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

View File

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

View File

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

View File

@ -1,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;
}
}

View File

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

View File

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

View File

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

View File

@ -1,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
View 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 (04). */
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 (04) 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 012, yellow 1336, 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
View 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
View 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

File diff suppressed because it is too large Load Diff

61
lib/text_sanitize.php Normal file
View 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;
}

View File

@ -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
View File

View File

@ -1,236 +0,0 @@
<?php
// /var/www/html/manage_users.php
// Admin-only endpoint for user management.
//
// GET → list all users with role entity details + list of supervisors
// POST → create a new user { username, password, role, location?, supervisorID? }
// DELETE → delete a user { userID }
//
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
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
View 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>

View File

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

270
schema.mysql.sql Normal file
View 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;

View File

@ -32,9 +32,18 @@ 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 '',
@ -42,7 +51,19 @@ CREATE TABLE IF NOT EXISTS questionnaire (
state TEXT NOT NULL DEFAULT '',
orderIndex INTEGER NOT NULL DEFAULT 0,
showPoints INTEGER NOT NULL DEFAULT 0,
conditionJson TEXT NOT NULL DEFAULT '{}'
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 (
@ -53,6 +74,8 @@ CREATE TABLE IF NOT EXISTS question (
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);

View 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 14 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 24 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()

View File

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

View File

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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']);
}
}

View 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);
}
}

View 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);
}
}

View 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