Compare commits

1 Commits

Author SHA1 Message Date
06fc134299 just testing the environment
Some checks failed
Tests / test (push) Has been cancelled
2026-05-20 07:10:08 +02:00
218 changed files with 9359 additions and 32327 deletions

View File

@ -1,12 +0,0 @@
# 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=

View File

@ -0,0 +1,44 @@
# Gitea Actions (act_runner). Requires a runner with label "ubuntu-latest".
# Instance: enable [actions] in app.ini; repo: Settings → Actions → enabled.
# If checkout/setup actions fail offline, set DEFAULT_ACTIONS_URL = github (or mirror actions).
name: Tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo_sqlite, openssl, json
coverage: none
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
composer install --no-interaction
npm install
npm install --prefix website
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: PHPUnit
run: composer test
- name: Website unit tests (Vitest)
run: npm run test:unit --prefix website
- name: End-to-end tests (Playwright)
run: CI=true npm run test:e2e
env:
QDB_MASTER_KEY: dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==

View File

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

15
.gitignore vendored
View File

@ -7,8 +7,11 @@ valid_tokens.txt
# Encrypted database, temp files, backups
uploads/*
!uploads/.gitkeep
!uploads/logs/
!uploads/logs/**
# Test runtime artifacts
tests/var/*
!tests/var/uploads/.gitkeep
tests/.phpunit.cache/
# Database lock files (just in case)
uploads/.qdb_lock
@ -16,8 +19,6 @@ uploads/*.lock
# Logs / dumps / exports
*.log
logs/api/*
!logs/api/.gitkeep
*.sqlite
*.db
*.dump
@ -33,8 +34,6 @@ yarn.lock
package-lock.json
# PHP composer artifacts (if using composer in future)
tests/runtime/
.phpunit.cache/
vendor/
composer.lock
@ -51,7 +50,9 @@ Thumbs.db
# Misc
*.orig
*.rej
*.md
# Ignore schema if generated
schema.sql
# Ignore browser build artifacts (if any)
website/js/dist/

146
TESTING.md Normal file
View File

@ -0,0 +1,146 @@
# Testing
Automated tests cover the PHP API (`api/`, `handlers/`), shared libraries, and the admin SPA in `website/`. Legacy root PHP scripts and duplicate `api/*.php` files are **not** tested.
## Requirements
- **PHP 8.x** with extensions: `pdo_sqlite`, `openssl`, `json`
- **Composer**
- **Node.js 20+** and npm
### PHP: enable SQLite (Windows)
If `composer test` fails with `PDOException: could not find driver`, PDO has no SQLite driver loaded. Check:
```powershell
php -r "print_r(PDO::getAvailableDrivers());"
```
You should see `sqlite` in the list. If not, edit the `php.ini` shown by `php --ini` and uncomment:
```ini
extension=pdo_sqlite
extension=sqlite3
```
Restart the terminal, then run the check again. Wingets PHP package uses a path like:
`%LOCALAPPDATA%\Microsoft\WinGet\Packages\PHP.PHP.8.4_*\php.ini`
## One-time setup
```bash
composer install
npm install
cd website && npm install && cd ..
npx playwright install
```
## Test database key
Tests use a fixed master key (not for production):
```text
QDB_MASTER_KEY=dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==
```
PHPUnit writes to `tests/var/uploads/`. Playwright and manual E2E seed `uploads/` via:
```bash
php tests/bin/seed-test-db.php
```
### Seeded accounts
| Username | Password | Role |
|-----------------|---------------|-------------|
| testadmin | testpass123 | admin |
| testsuper1 | testpass123 | supervisor |
| testcoach1 | testpass123 | coach |
| testmustchange | testpass123 | coach (must change password on first login) |
Sample questionnaire ID: `questionnaire_test_demo` (active, with question `q1`).
## Run tests
### All suites
From the repository root (PHPUnit, Vitest, then Playwright):
```bash
npm run test
```
Alias: `npm run test:all`.
### PHP (unit + integration)
```bash
composer test
```
Integration tests reset the database before **each** test method (isolated state; slightly slower).
### Website unit (Vitest)
```bash
cd website && npm run test:unit
```
### End-to-end (Playwright)
Starts `php -S 127.0.0.1:8080 dev-router.php`, seeds the DB, then runs HTTP smoke tests and website UI tests. Detailed API behaviour is covered by PHPUnit; Playwright API tests are smoke + password-change flow only.
```bash
npm run test:e2e
```
### Continuous integration (Gitea Actions)
Workflow: [`.gitea/workflows/tests.yml`](.gitea/workflows/tests.yml) — same three steps on push and pull request (`CI=true` for E2E so the dev server is always started fresh).
**Prerequisites on your Gitea instance:**
1. **Enable Actions** in `app.ini`:
```ini
[actions]
ENABLED = true
```
2. **Register an [act_runner](https://gitea.com/gitea/act_runner)** on a Linux host (needs Docker or host tools for Playwright system deps). Use a label that matches the workflow, e.g. `ubuntu-latest`:
```bash
act_runner register --instance https://your-gitea.example --token <token>
act_runner daemon
```
3. **Enable Actions** on this repository (Settings → Actions).
4. **Third-party actions** (`actions/checkout`, `setup-php`, `setup-node`) are fetched from GitHub by default (`DEFAULT_ACTIONS_URL = github`). On air-gapped instances, mirror those actions or switch the workflow to shell `apt`/`composer` installs.
Gitea also reads `.github/workflows/` for migrated repos; this project uses **`.gitea/workflows/`** only to avoid duplicate runs.
Interactive UI:
```bash
npm run test:e2e:ui
```
## Manual development server
```bash
# Optional: seed test data into uploads/
php tests/bin/seed-test-db.php
$env:QDB_MASTER_KEY="dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ=="
php -S 127.0.0.1:8080 dev-router.php
```
Open [http://127.0.0.1:8080/website/](http://127.0.0.1:8080/website/).
## Layout
```text
.gitea/workflows/ Gitea Actions CI (tests.yml)
tests/ PHPUnit (Unit/, Integration/, Support/)
tests/fixtures/ schema.sql copy for tests
website/tests/ Vitest unit tests
e2e/api/ Playwright HTTP smoke tests (Android API)
e2e/website/ Playwright browser tests (hash routes)
```

185
api/answer_options.php Normal file
View File

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

176
api/app_questionnaires.php Normal file
View File

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

117
api/assignments.php Normal file
View File

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

72
api/dispatch.php Normal file
View File

@ -0,0 +1,72 @@
<?php
/**
* Dispatch a single API request. Used by index.php and PHPUnit (multiple calls per process).
*/
function qdb_dispatch_api_request(): void
{
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
require_once __DIR__ . '/../lib/response.php';
require_once __DIR__ . '/../lib/validate.php';
if (!headers_sent()) {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Content-Type: application/json; charset=UTF-8');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
if (!defined('QDB_TESTING') || !QDB_TESTING) {
http_response_code(204);
exit;
}
throw new QdbHttpResponse([], 204);
}
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$path = parse_url($requestUri, PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
$route = trim(substr($path, strlen($base)), '/');
$route = preg_replace('/\.php$/', '', $route);
$route = strtolower($route);
$method = $_SERVER['REQUEST_METHOD'];
$routes = [
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
'questions' => __DIR__ . '/../handlers/questions.php',
'answer_options' => __DIR__ . '/../handlers/answer_options.php',
'translations' => __DIR__ . '/../handlers/translations.php',
'users' => __DIR__ . '/../handlers/users.php',
'assignments' => __DIR__ . '/../handlers/assignments.php',
'clients' => __DIR__ . '/../handlers/clients.php',
'results' => __DIR__ . '/../handlers/results.php',
'export' => __DIR__ . '/../handlers/export.php',
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
'logout' => __DIR__ . '/../handlers/logout.php',
'auth/login' => __DIR__ . '/../handlers/auth.php',
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
'backup' => __DIR__ . '/../handlers/backup.php',
'download' => __DIR__ . '/../handlers/download.php',
];
try {
if (!isset($routes[$route])) {
json_error('NOT_FOUND', "Unknown endpoint: $route", 404);
}
$handlerFile = $routes[$route];
if (!file_exists($handlerFile)) {
json_error('NOT_FOUND', "Handler not found for: $route", 404);
}
require $handlerFile;
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
error_log("Unhandled error on $method $route: " . $e->getMessage());
json_error('SERVER_ERROR', 'Internal server error', 500);
}
}

138
api/export.php Normal file
View File

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

@ -1,91 +1,6 @@
<?php
/**
* Front-controller: single entry point for all /api/ requests.
* Handles CORS, JSON content type, global error catching, and dispatch.
*/
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';
// Parse route early (for access logs)
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$path = parse_url($requestUri, PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
$route = trim(substr($path, strlen($base)), '/');
$route = preg_replace('/\.php$/', '', $route);
$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',
'questions' => __DIR__ . '/../handlers/questions.php',
'answer_options' => __DIR__ . '/../handlers/answer_options.php',
'translations' => __DIR__ . '/../handlers/translations.php',
'users' => __DIR__ . '/../handlers/users.php',
'assignments' => __DIR__ . '/../handlers/assignments.php',
'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',
'dev' => __DIR__ . '/../handlers/dev.php',
'dev/import' => __DIR__ . '/../handlers/dev.php',
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
'settings' => __DIR__ . '/../handlers/settings.php',
];
try {
if (!isset($routes[$route])) {
json_error('NOT_FOUND', "Unknown endpoint: $route", 404);
}
$handlerFile = $routes[$route];
if (!file_exists($handlerFile)) {
json_error('NOT_FOUND', "Handler not found for: $route", 404);
}
require $handlerFile;
} catch (Throwable $e) {
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");
}
require_once __DIR__ . '/dispatch.php';
qdb_dispatch_api_request();

21
api/logout.php Normal file
View File

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

169
api/questionnaires.php Normal file
View File

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

215
api/questions.php Normal file
View File

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

139
api/results.php Normal file
View File

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

291
api/translations.php Normal file
View File

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

275
api/users.php Normal file
View File

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

118
assign_client.php Normal file
View File

@ -0,0 +1,118 @@
<?php
// /var/www/html/assign_client.php
// GET: Returns list of coaches and clients (filtered by role)
// POST: Assigns one or more clients to a coach
require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$role = $tokenRec['role'];
$entityID = $tokenRec['entityID'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
// Coaches visible to this user
if ($role === 'admin') {
$coaches = $pdo->query(
"SELECT c.coachID, c.username, c.supervisorID FROM coach c ORDER BY c.username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$stmt = $pdo->prepare(
"SELECT c.coachID, c.username, c.supervisorID FROM coach c WHERE c.supervisorID = :sid ORDER BY c.username"
);
$stmt->execute([':sid' => $entityID]);
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Clients visible to this user
[$clause, $params] = rbac_client_filter($tokenRec);
$clientStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.clientCode"
);
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
$clientStmt->execute();
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
$pdo = null;
qdb_discard($tmpDb, $lockFp);
echo json_encode(["coaches" => $coaches, "clients" => $clients]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["error" => "Server error"]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = file_get_contents("php://input");
$in = json_decode($raw, true) ?: [];
$clientCodes = $in['clientCodes'] ?? [];
$coachID = trim($in['coachID'] ?? '');
if (empty($clientCodes) || $coachID === '') {
http_response_code(400);
echo json_encode(["error" => "clientCodes and coachID required"]);
exit;
}
if (!is_array($clientCodes)) {
$clientCodes = [$clientCodes];
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
// Verify coach exists and is visible
if ($role === 'supervisor') {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chk->execute([':cid' => $coachID, ':sid' => $entityID]);
} else {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chk->execute([':cid' => $coachID]);
}
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
http_response_code(400);
echo json_encode(["error" => "Coach not found or not authorized"]);
exit;
}
// Only allow reassignment of clients the caller can already see
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$pdo->beginTransaction();
$updStmt = $pdo->prepare(
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
);
$count = 0;
foreach ($clientCodes as $cc) {
$cc = trim($cc);
if ($cc === '') continue;
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
$count += $updStmt->rowCount();
}
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
echo json_encode(["success" => true, "assigned" => $count]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
http_response_code(500);
echo json_encode(["error" => "Server error"]);
}
exit;
}
http_response_code(405);
echo json_encode(["error" => "Method not allowed"]);

42
backup.php Normal file
View File

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

103
change_password.php Normal file
View File

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

10
checkDatabaseExists.php Executable file
View File

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

View File

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

View File

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

@ -1,80 +0,0 @@
#!/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";

2573
common.php

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,8 @@
{
"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": "*"
},
"description": "Questionnaire management API and admin website",
"require-dev": {
"phpunit/phpunit": "^11.5"
"phpunit/phpunit": "^11"
},
"autoload-dev": {
"psr-4": {
@ -19,8 +10,6 @@
}
},
"scripts": {
"test": "phpunit",
"test:coverage": "phpunit --coverage-text",
"test:ci": "php tests/check-coverage.php 40"
"test": "phpunit -c tests/phpunit.xml"
}
}

Binary file not shown.

View File

@ -1,340 +0,0 @@
{
"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

@ -1,190 +0,0 @@
Ä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

46
db_download.php Normal file
View File

@ -0,0 +1,46 @@
<?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,542 +1,108 @@
<?php
// Shared helpers for opening, creating, and saving the questionnaire database.
// /var/www/html/db_init.php
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/lib/sql_dialect.php';
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', 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();
if (!defined('QDB_PATH')) {
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
}
if (!defined('QDB_LOCK')) {
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
}
if (!defined('QDB_SCHEMA')) {
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
}
define('QDB_VERSION', 3);
/**
* 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 = qdb_open_writable_lock();
$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");
}
}
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_');
$masterKey = get_master_key_bytes();
$sql = file_get_contents(QDB_SCHEMA);
if ($sql === false) {
flock($lockFp, LOCK_UN);
fclose($lockFp);
throw new Exception('Could not read schema.sql');
if ($lockFp) { 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) {
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 ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not read stored DB");
}
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
if (file_put_contents($tmpDb, $decrypted) === false) {
flock($lockFp, LOCK_UN);
fclose($lockFp);
throw new Exception('Could not write temp DB');
if ($lockFp) { 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);
qdb_set_schema_version($pdo, QDB_VERSION);
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
$pdo = null;
}
$pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
qdb_set_foreign_keys($pdo, true);
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_apply_migrations($pdo, $sql);
// 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;");
}
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);
@ -546,41 +112,15 @@ function qdb_save(string $tmpDb, $lockFp): void {
flock($lockFp, LOCK_UN);
fclose($lockFp);
}
require_once __DIR__ . '/lib/read_db_cache.php';
qdb_read_cache_invalidate();
}
/**
* Discard changes -- clean up temp file and release lock without saving.
*/
function qdb_discard(string $tmpDb, $lockFp): void {
if (qdb_is_mysql()) {
return;
}
if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) {
return;
}
@unlink($tmpDb);
if ($lockFp) {
@flock($lockFp, LOCK_UN);
@fclose($lockFp);
}
}
/** @return array{0: PDO, 1: string, 2: resource|null} */
function qdb_open_read_or_fail(): array {
try {
return qdb_open(false);
} catch (Throwable $e) {
require_once __DIR__ . '/lib/errors.php';
qdb_emit_api_error($e, 'Database read');
}
}
/** @return array{0: PDO, 1: string, 2: resource|null} */
function qdb_open_write_or_fail(): array {
try {
return qdb_open(true);
} catch (Throwable $e) {
require_once __DIR__ . '/lib/errors.php';
qdb_emit_api_error($e, 'Database write');
}
}

78
db_view.php Normal file
View File

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

141
db_view_ordered.php Normal file
View File

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

38
dev-router.php Normal file
View File

@ -0,0 +1,38 @@
<?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,34 +31,7 @@ Use the front-controller routes under `/api/...`. The clean route `/api/app_ques
}
```
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" }
]
}
}
}
```
Some low-level auth failures can still return a legacy body such as `{"error":"Missing Bearer token"}` or `{"error":"Invalid or expired token"}`.
## Authentication
@ -77,49 +50,6 @@ 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,
@ -131,16 +61,7 @@ Success:
}
```
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.
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.
## Fetch Questionnaires
@ -176,7 +97,6 @@ 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`.
@ -231,62 +151,10 @@ 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`, `precision`, `dateRange`, `minSelection`: optional layout-specific config fields.
- `dateRange` (date_spinner): `"past"` (default) or `"future"` — which years/dates the app offers.
- `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `minSelection`: optional layout-specific config fields.
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`
@ -310,7 +178,6 @@ Success:
"clients": [
{
"clientCode": "CLIENT-001",
"note": "Call back next week",
"completedQuestionnaires": [
{
"questionnaireID": "questionnaire_1_demographic_information",
@ -321,7 +188,6 @@ Success:
},
{
"clientCode": "CLIENT-002",
"note": "",
"completedQuestionnaires": []
}
]
@ -333,7 +199,6 @@ 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.
@ -348,8 +213,6 @@ 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
@ -372,74 +235,32 @@ Success:
}
```
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.
The translation map is grouped by language code. Each language contains a flat key-value map.
Android lookup for app UI:
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:
1. Load `translations[activeLanguage]`.
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.
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.
## Recommended Android Sync Flow
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).
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`.
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`
@ -448,24 +269,6 @@ 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
@ -517,9 +320,8 @@ 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 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`.
- 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.
- `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.

51
downloadFull.php Executable file
View File

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

View File

@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
const API = '/api';
const PASSWORD = 'testpass123';
async function login(request: import('@playwright/test').APIRequestContext, username: string) {
const res = await request.post(`${API}/auth/login`, {
data: { username, password: PASSWORD },
});
const json = await res.json();
expect(json.ok).toBe(true);
return json.data.token as string;
}
test.describe('Android API app_questionnaires (HTTP smoke)', () => {
test('list active questionnaires over HTTP', async ({ request }) => {
const token = await login(request, 'testcoach1');
const res = await request.get(`${API}/app_questionnaires`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.some((q: { id: string }) => q.id === 'questionnaire_test_demo')).toBe(true);
});
test('missing bearer returns 401', async ({ request }) => {
const res = await request.get(`${API}/app_questionnaires`);
expect(res.status()).toBe(401);
});
test('password change flow', async ({ request }) => {
const loginRes = await request.post(`${API}/auth/login`, {
data: { username: 'testmustchange', password: PASSWORD },
});
const loginJson = await loginRes.json();
expect(loginJson.data.mustChangePassword).toBe(true);
const tempToken = loginJson.data.token as string;
let fullToken: string | undefined;

25
e2e/api/auth.spec.ts Normal file
View File

@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';
const API = '/api';
test.describe('Android API auth', () => {
test('login returns token', async ({ request }) => {
const res = await request.post(`${API}/auth/login`, {
data: { username: 'testadmin', password: 'testpass123' },
});
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.token).toBeTruthy();
expect(json.data.role).toBe('admin');
});
test('bad password returns error', async ({ request }) => {
const res = await request.post(`${API}/auth/login`, {
data: { username: 'testadmin', password: 'wrong' },
});
expect(res.status()).toBe(401);
const json = await res.json();
expect(json.ok).toBe(false);
});
});

16
e2e/global-setup.js Normal file
View File

@ -0,0 +1,16 @@
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export default async function globalSetup() {
execSync('php tests/bin/seed-test-db.php', {
cwd: root,
stdio: 'inherit',
env: {
...process.env,
QDB_MASTER_KEY: 'dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==',
},
});
}

16
e2e/website/helpers.ts Normal file
View File

@ -0,0 +1,16 @@
import { Page, expect } from '@playwright/test';
export const TEST_PASSWORD = 'testpass123';
export async function loginAs(page: Page, username: string, password = TEST_PASSWORD) {
await page.goto('/website/#/login');
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
await page.locator('#loginForm button[type="submit"]').click();
await expect(page.locator('h1')).toContainText('Questionnaires', { timeout: 15000 });
}
export async function logout(page: Page) {
await page.locator('#logoutBtn').click();
await expect(page.locator('#loginForm')).toBeVisible();
}

21
e2e/website/login.spec.ts Normal file
View File

@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test';
test.describe('Website login', () => {
test('shows login form', async ({ page }) => {
await page.goto('/website/#/login');
await expect(page.locator('#loginForm')).toBeVisible();
});
test('bad password shows error', async ({ page }) => {
await page.goto('/website/#/login');
await page.locator('#username').fill('testadmin');
await page.locator('#password').fill('wrong-password');
await page.locator('#loginForm button[type="submit"]').click();
await expect(page.locator('#loginError')).toBeVisible();
});
test('protected route redirects to login', async ({ page }) => {
await page.goto('/website/#/users');
await expect(page.locator('#loginForm')).toBeVisible();
});
});

View File

@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import { loginAs, logout } from './helpers';
test.describe('Website routes (admin)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'testadmin');
});
test('dashboard lists questionnaires', async ({ page }) => {
await expect(page.locator('h1')).toHaveText('Questionnaires');
await expect(page.locator('.q-card, .empty-state')).toBeVisible();
});
test('export page', async ({ page }) => {
await page.goto('/website/#/export');
await expect(page.locator('h1')).toContainText('Export');
});
test('users page for admin', async ({ page }) => {
await page.goto('/website/#/users');
await expect(page.locator('h1')).toContainText('Users');
});
test('clients page', async ({ page }) => {
await page.goto('/website/#/clients');
await expect(page.locator('h1')).toContainText('Clients');
});
test('assignments page', async ({ page }) => {
await page.goto('/website/#/assignments');
await expect(page.locator('h1')).toContainText('Assign');
});
test('editor for seeded questionnaire', async ({ page }) => {
await page.goto('/website/#/questionnaire/questionnaire_test_demo');
await expect(page.locator('#editorTitle')).not.toHaveText('Loading...');
await expect(page.locator('#editorContent')).toContainText('Questions');
});
test('results page', async ({ page }) => {
await page.goto('/website/#/questionnaire/questionnaire_test_demo/results');
await expect(page.locator('h1')).toContainText('Results');
});
test('logout clears session', async ({ page }) => {
await logout(page);
await page.goto('/website/#/');
await expect(page.locator('#loginForm')).toBeVisible();
});
});
test.describe('Website role nav', () => {
test('coach cannot see admin nav items', async ({ page }) => {
await loginAs(page, 'testcoach1');
const usersNav = page.locator('[data-nav-roles] a[href="#/users"]');
await expect(usersNav).toBeHidden();
});
test('supervisor sees users nav', async ({ page }) => {
await loginAs(page, 'testsuper1');
const usersNav = page.locator('[data-nav-roles] a[href="#/users"]');
await expect(usersNav).toBeVisible();
});
});

View File

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

View File

@ -1,51 +0,0 @@
<?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,8 +1,6 @@
<?php
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
@ -12,80 +10,66 @@ case 'GET':
if (!$qID) {
json_error('MISSING_PARAM', 'questionID query param required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex');
$stmt->execute([':qid' => $qID]);
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($options as &$o) {
$o['points'] = (int)$o['points'];
$o['orderIndex'] = (int)$o['orderIndex'];
$o['optionKey'] = qdb_option_key($o);
$o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']);
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
$tr->execute([':id' => $o['answerOptionID']]);
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($o);
qdb_discard($tmpDb, $lockFp);
json_success(['answerOptions' => $options]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load answer options', null, $tmpDb ?? null, $lockFp ?? null);
[$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);
json_success(['answerOptions' => $options]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID']) || empty($body['optionKey']) || !isset($body['defaultText'])) {
json_error('MISSING_FIELDS', 'questionID, optionKey, and defaultText (German label) required', 400);
if (empty($body['questionID']) || !isset($body['defaultText'])) {
json_error('MISSING_FIELDS', 'questionID and defaultText 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_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id');
$chk = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id');
$chk->execute([':id' => $qID]);
$qnID = $chk->fetchColumn();
if (!$qnID) {
if (!$chk->fetch()) {
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' => $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');
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev,
'answerOption' => [
json_success(['answerOption' => [
'answerOptionID' => $id,
'questionID' => $qID,
'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $text,
'defaultText' => $text,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
'translations' => [],
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -97,7 +81,7 @@ case 'PUT':
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare('SELECT * FROM answer_option WHERE answerOptionID = :id');
$existing->execute([':id' => $id]);
@ -106,46 +90,28 @@ case 'PUT':
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$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);
$text = trim($body['defaultText'] ?? $row['defaultText']);
$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' => $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');
}
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1),
'answerOption' => [
json_success(['answerOption' => [
'answerOptionID' => $id,
'questionID' => $row['questionID'],
'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $labelGerman,
'defaultText' => $text,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -157,39 +123,26 @@ case 'DELETE':
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare('SELECT questionID FROM answer_option WHERE answerOptionID = :id');
$existing->execute([':id' => $id]);
$qid = $existing->fetchColumn();
if (!$qid) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$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->exec('PRAGMA foreign_keys = OFF;');
$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('UPDATE client_answer SET answerOptionID = NULL 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();
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);
json_success(['deleted' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -199,7 +152,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_write_or_fail();
[$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) {
@ -207,8 +160,12 @@ case 'PATCH':
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;

View File

@ -3,110 +3,17 @@
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
* GET ?translations=1 -> all translations merged across all tables
* GET ?clients=1 -> list of clients assigned to the authenticated coach
* 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).
* POST -> submit interview answers for a client (coach / supervisor / admin)
*/
// 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_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);
}
}
$body = read_json_body();
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']);
@ -121,27 +28,15 @@ if ($method === 'POST') {
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qnStmt = $pdo->prepare(
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire WHERE questionnaireID = :id'
);
// Verify questionnaire exists
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
$qnStmt->execute([':id' => $qnID]);
$qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
if (!$qnStmt->fetch()) {
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(
@ -158,122 +53,63 @@ if ($method === 'POST') {
? ($tokenRec['entityID'] ?? '')
: ($clientRow['coachID'] ?? '');
$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 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;
}
$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]
);
// 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'],
];
}
$pdo->beginTransaction();
$glassByParent = [];
$submittedQuestionIds = [];
$answerInsert = $pdo->prepare('
$sumPoints = 0;
$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',
]
)
);
$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);
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
");
foreach ($answers as $a) {
$shortId = trim($a['questionID'] ?? '');
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;
}
if ($shortId === '') continue;
// Resolve short ID to full questionID
$fullQID = $shortIdMap[$shortId] ?? null;
if ($fullQID === null) {
continue;
}
if ($fullQID === null) continue; // skip unknown questions
$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;
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)
// Resolve option key to answerOptionID and accumulate points
$optionKey = $a['answerOptionKey'] ?? null;
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
$opt = $optionMap[$fullQID][(string)$optionKey];
$answerOptionID = $opt['answerOptionID'];
}
$hasValue = $answerOptionID !== null
|| ($freeTextValue !== null && $freeTextValue !== '')
|| $numericValue !== null;
if (!$hasValue) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
$sumPoints += $opt['points'];
}
$answerInsert->execute([
@ -284,60 +120,19 @@ if ($method === 'POST') {
':nv' => $numericValue,
':at' => $answeredAt,
]);
$submittedQuestionIds[$fullQID] = true;
}
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('
// Upsert completed_questionnaire record
$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([
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([
':cc' => $clientCode,
':qn' => $qnID,
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
@ -346,34 +141,17 @@ 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,
'structureRevision' => $submitRev,
'legacySubmit' => $isLegacySubmit,
]);
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
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);
}
}
@ -381,93 +159,11 @@ if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$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);
@ -477,14 +173,13 @@ if ($fetchClients) {
$stmt = $pdo->prepare("
SELECT clientCode
FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
WHERE coachID = :cid
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("
@ -500,32 +195,50 @@ 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, $notesByClient) {
$clients = array_map(function ($code) use ($completions) {
return [
'clientCode' => $code,
'clientCode' => $code,
'completedQuestionnaires' => $completions[$code] ?? [],
'note' => $notesByClient[$code] ?? '',
];
}, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
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]);
}
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);
@ -534,12 +247,10 @@ if ($qnID) {
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$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 = $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);
@ -550,21 +261,14 @@ if ($qnID) {
$shortId = end($parts);
$layout = $dbQ['type'];
$config = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$config = json_decode($dbQ['configJson'], true) ?: [];
$q = [
'id' => $shortId,
'layout' => $layout,
'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
'question' => $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'];
@ -572,42 +276,21 @@ 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' || $layout === 'slider_question') && isset($config['valueOptions'])) {
if ($layout === 'value_spinner' && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions'];
}
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 = $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);
@ -622,32 +305,20 @@ 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;
}
$q['options'] = $optionsArr;
$q['pointsMap'] = $pointsMap;
}
$questions[] = $q;
}
qdb_discard($tmpDb, $lockFp);
json_success([
'meta' => ['id' => $qnID],
'structureRevision' => $structureRevision,
'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
'availableLanguages' => qdb_available_language_codes($pdo, $qnID),
]);
json_success(["meta" => ["id" => $qnID], "questions" => $questions]);
}
// Default: ordered questionnaire list
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
COALESCE(structureRevision, 1) AS structureRevision
SELECT questionnaireID, name, showPoints, conditionJson
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
@ -655,18 +326,12 @@ $rows = $pdo->query("
$list = [];
foreach ($rows as $r) {
$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(),
$list[] = [
'id' => $r['questionnaireID'],
'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'],
'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,6 +1,7 @@
<?php
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
@ -9,7 +10,7 @@ switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($callerRole === 'admin') {
$coaches = $pdo->query(
@ -43,8 +44,12 @@ case 'GET':
qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches, 'clients' => $clients]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'assignments', null, $tmpDb ?? null, $lockFp ?? null);
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -59,7 +64,7 @@ case 'POST':
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
if ($callerRole === 'supervisor') {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
@ -70,7 +75,7 @@ case 'POST':
}
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
json_error('NOT_FOUND', 'Coach not found or not authorized', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
@ -89,8 +94,13 @@ case 'POST':
qdb_save($tmpDb, $lockFp);
json_success(['assigned' => $count]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'assignments', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
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);
}
break;

View File

@ -1,9 +1,5 @@
<?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':
@ -19,53 +15,23 @@ 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_read_or_fail();
[$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);
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, qdb_session_ttl_seconds($securitySettings, true), [
token_add($tempToken, 10 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
@ -80,25 +46,22 @@ case 'auth/login':
}
$token = bin2hex(random_bytes(32));
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
token_add($token, 30 * 24 * 60 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
$loginData = [
json_success([
'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 (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null);
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -133,7 +96,7 @@ case 'auth/change-password':
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID FROM users WHERE username = :u"
@ -151,8 +114,6 @@ 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);
@ -163,15 +124,12 @@ 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);
$securitySettings = qdb_settings_get();
// Issue a fresh session for this browser only
// Revoke the old (possibly temp) token and issue a fresh one
token_revoke($bearerToken);
$newToken = bin2hex(random_bytes(32));
token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [
token_add($newToken, 30 * 24 * 60 * 60, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
@ -182,102 +140,12 @@ case 'auth/change-password':
'user' => $username,
'role' => $user['role'],
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
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);
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;

View File

@ -1,76 +1,23 @@
<?php
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
require_role(['admin'], $tokenRec);
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
$dbPath = QDB_PATH;
$backupDir = __DIR__ . '/../uploads/backups';
if (!file_exists($dbPath)) {
json_error('NOT_FOUND', 'No database to back up', 404);
}
$backupDir = QDB_UPLOADS_DIR . '/backups';
if (!is_dir($backupDir)) {
if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
json_error('SERVER_ERROR', 'Could not create backups directory', 500);
}
}
$timestamp = date('Y-m-d_H-i-s');
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";
$timestamp = date('Y-m-d_H-i-s');
$filename = "questionnaire_database.$timestamp";
$backupFile = "$backupDir/$filename";
if (!copy($dbPath, $backupFile)) {

View File

@ -1,6 +1,7 @@
<?php
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
@ -9,80 +10,44 @@ switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$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'
: '';
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
$stmt = $pdo->prepare(
"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
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
$noteJoin
WHERE $clause
ORDER BY cl.archived ASC, hasResponseData DESC, cl.clientCode ASC"
ORDER BY cl.clientCode"
);
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,
'scoringProfiles' => $scoringSummary['profiles'],
]);
json_success(['clients' => $clients]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'POST':
$body = read_json_body();
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
$coachID = trim($body['coachID'] ?? '');
$coachID = trim($body['coachID'] ?? '');
$bulk = !empty($body['bulk']);
if ($coachID === '') {
json_error('MISSING_FIELDS', 'coachID is required', 400);
if ($clientCode === '' || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
// 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]);
@ -92,58 +57,7 @@ case 'POST':
}
if (!$chkCoach->fetch()) {
qdb_discard($tmpDb, $lockFp);
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);
json_error('NOT_FOUND', 'Coach not found or not authorized', 400);
}
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
@ -153,107 +67,17 @@ case 'POST':
json_error('DUPLICATE', 'Client code already exists', 409);
}
$pdo->prepare(
"INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
)->execute([':cc' => $clientCode, ':cid' => $coachID]);
$pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)")
->execute([':cc' => $clientCode, ':cid' => $coachID]);
qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
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);
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -266,7 +90,7 @@ case 'DELETE':
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
// Verify the client exists and the caller can see it (RBAC)
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
@ -279,18 +103,24 @@ case 'DELETE':
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/submissions.php';
$pdo->beginTransaction();
qdb_delete_client_response_data($pdo, [$clientCode]);
$pdo->prepare('DELETE FROM client WHERE clientCode = :cc')
$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")
->execute([':cc' => $clientCode]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
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);
}
break;

View File

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

View File

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

75
handlers/download.php Normal file
View File

@ -0,0 +1,75 @@
<?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 (QdbHttpResponse $e) {
throw $e;
} 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,70 +1,19 @@
<?php
require_once __DIR__ . '/../lib/submissions.php';
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
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);
}
$allVersions = !empty($_GET['allVersions']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
@ -72,40 +21,99 @@ if (!$questionnaire) {
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
$questions = $ctx['questions'];
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
$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');
if ($allVersions) {
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
qdb_discard($tmpDb, $lockFp);
$safeName = qdb_export_safe_basename($questionnaire['name']);
$filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv';
qdb_http_download(
qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($pdo, $resultColumns)),
[
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
$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;
}
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
qdb_discard($tmpDb, $lockFp);
$safeName = qdb_export_safe_basename($questionnaire['name']);
// CSV output (not JSON -- overrides the router's Content-Type)
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
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);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
if (!empty($rows)) {
fputcsv($out, array_keys($rows[0]));
foreach ($rows as $row) {
fputcsv($out, array_values($row));
}
} else {
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) $header[] = $q['defaultText'];
fputcsv($out, $header);
}
fclose($out);

View File

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

View File

@ -1,123 +1,33 @@
<?php
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
switch ($method) {
case 'GET':
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT q.questionnaireID, q.name, q.version, q.state,
q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey,
COUNT(qu.questionID) AS questionCount,
(
SELECT COUNT(*)
FROM completed_questionnaire cq
INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE cq.questionnaireID = q.questionnaireID
AND $rbacClause
) AS completedCount
FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
GROUP BY q.questionnaireID
ORDER BY q.orderIndex, q.name
";
$stmt = $pdo->prepare($sql);
$stmt->execute($rbacParams);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['orderIndex'] = (int)$r['orderIndex'];
$r['showPoints'] = (int)$r['showPoints'];
$r['questionCount'] = (int)$r['questionCount'];
$r['completedCount'] = (int)$r['completedCount'];
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
}
unset($r);
$categoryKeys = qdb_list_category_keys_for_dashboard($pdo);
qdb_discard($tmpDb, $lockFp);
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load questionnaires', null, $tmpDb ?? null, $lockFp ?? null);
[$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);
json_success(['questionnaires' => $rows]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (($body['action'] ?? '') === 'updateConditionMessageLabel') {
$messageKey = trim((string)($body['messageKey'] ?? ''));
$germanLabel = (string)($body['germanLabel'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
qdb_set_app_string_german_label($pdo, $messageKey, $germanLabel);
qdb_save($tmpDb, $lockFp);
json_success([
'messageKey' => qdb_validate_stable_key($messageKey, 'messageKey'),
'germanLabel' => trim($germanLabel),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update condition message label', null, $tmpDb, $lockFp);
}
break;
}
if (($body['action'] ?? '') === 'updateCategoryLabel') {
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
$germanLabel = (string)($body['germanLabel'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$stringKey = qdb_set_category_german_label($pdo, $categoryKey, $germanLabel);
qdb_save($tmpDb, $lockFp);
json_success([
'categoryKey' => $categoryKey,
'stringKey' => $stringKey,
'germanLabel' => trim($germanLabel),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update category label', null, $tmpDb, $lockFp);
}
break;
}
if (($body['action'] ?? '') === 'deleteCategoryKey') {
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
if ($categoryKey === '') {
json_error('INVALID_FIELD', 'categoryKey is required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$cleared = qdb_delete_category_key($pdo, $categoryKey);
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => $categoryKey, 'questionnairesCleared' => $cleared]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete category key', null, $tmpDb, $lockFp);
}
break;
}
if (($body['action'] ?? '') === 'importBundle') {
$bundle = $body['bundle'] ?? null;
if (!is_array($bundle)) {
json_error('MISSING_FIELDS', 'bundle object is required', 400);
}
$replaceIfExists = !empty($body['replaceIfExists']);
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
qdb_set_foreign_keys($pdo, false);
$result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists);
qdb_set_foreign_keys($pdo, true);
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Import questionnaires', null, $tmpDb, $lockFp);
}
break;
}
if (empty($body['name'])) {
json_error('NAME_REQUIRED', 'name is required', 400);
}
@ -129,27 +39,27 @@ case 'POST':
$showPts = (int)($body['showPoints'] ?? 0);
$condJson = $body['conditionJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($order === 0) {
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
}
$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)")
$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, ':ck' => $catKey]);
':o' => $order, ':sp' => $showPts, ':cj' => $condJson]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version,
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
'conditionJson' => $condJson, 'questionCount' => 0,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Create questionnaire', null, $tmpDb, $lockFp);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -161,7 +71,7 @@ case 'PUT':
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$existing->execute([':id' => $id]);
@ -176,25 +86,23 @@ 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, categoryKey = :ck
orderIndex = :o, showPoints = :sp, conditionJson = :cj
WHERE questionnaireID = :id")
->execute([':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey, ':id' => $id]);
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update questionnaire', null, $tmpDb, $lockFp);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -206,9 +114,9 @@ case 'DELETE':
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
qdb_set_foreign_keys($pdo, false);
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->beginTransaction();
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
@ -231,11 +139,18 @@ case 'DELETE':
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->commit();
qdb_set_foreign_keys($pdo, true);
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete questionnaire', $pdo, $tmpDb, $lockFp);
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;

View File

@ -1,9 +1,6 @@
<?php
require_once __DIR__ . '/../lib/scoring.php';
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
switch ($method) {
@ -12,92 +9,50 @@ case 'GET':
if (!$qnID) {
json_error('BAD_REQUEST', 'questionnaireID query param required', 400);
}
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"
);
[$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);
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['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'
);
$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'];
$opt['optionKey'] = qdb_option_key($opt);
$opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']);
}
unset($opt);
if (($q['type'] ?? '') === 'glass_scale_question') {
$q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
}
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($q);
qdb_discard($tmpDb, $lockFp);
json_success([
'questions' => $questions,
'structureRevision' => $structureRevision,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null);
}
unset($q);
qdb_discard($tmpDb, $lockFp);
json_success(['questions' => $questions]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID']) || !isset($body['defaultText']) || empty($body['questionKey'])) {
json_error('BAD_REQUEST', 'questionnaireID, questionKey, and defaultText required', 400);
if (empty($body['questionnaireID']) || !isset($body['defaultText'])) {
json_error('BAD_REQUEST', 'questionnaireID 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);
$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();
$config = $body['configJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
$chk->execute([':id' => $qnID]);
@ -110,47 +65,22 @@ 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' => $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');
':o' => $order, ':r' => $req, ':cj' => $config]);
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev,
'question' => [
json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
'questionKey' => $questionKey, 'localId' => $localId,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'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) : [],
],
]);
'configJson' => $config, 'answerOptions' => [], 'translations' => [],
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -161,7 +91,7 @@ case 'PUT':
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
$existing->execute([':id' => $id]);
@ -171,71 +101,24 @@ 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']);
$oldCfg = qdb_parse_config_json($row['configJson']);
$questionKey = isset($body['questionKey'])
? qdb_validate_stable_key((string)$body['questionKey'], 'Question key')
: qdb_question_key($oldCfg, $row['defaultText']);
if ($questionKey === '') {
json_error('MISSING_FIELDS', 'questionKey is required', 400);
}
$cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg;
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfgIn);
if ($type === 'glass_scale_question') {
$cfgIn = qdb_apply_glass_symptoms_config($cfgIn, $glassSymptoms);
}
$config = qdb_normalize_question_config(
$cfgIn,
$questionKey,
$body['noteBefore'] ?? ($cfgIn['noteBefore'] ?? null),
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
);
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
$oldKey = qdb_question_key($oldCfg, $row['defaultText']);
$oldSymptoms = json_encode($oldCfg['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
$newSymptoms = json_encode($config['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
$structuralChange = ($type !== (string)$row['type'])
|| ($req !== (int)$row['isRequired'])
|| ($questionKey !== $oldKey)
|| ($newSymptoms !== $oldSymptoms);
if ((int)($row['retiredAt'] ?? 0) > 0) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'Cannot edit a retired question', 400);
}
$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' => $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');
}
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
'question' => [
json_success(['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) : [],
],
]);
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -246,57 +129,32 @@ case 'DELETE':
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$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->exec("PRAGMA foreign_keys = OFF;");
$pdo->beginTransaction();
$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 = $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_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->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);
json_success([
'deleted' => true,
'retired' => false,
'structureRevision' => $newRev,
]);
json_success(['deleted' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -306,49 +164,20 @@ case 'PATCH':
if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400);
}
$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);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$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]);
$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);
json_success(['reordered' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Reorder questions', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;

View File

@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../lib/submissions.php';
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
@ -15,11 +13,9 @@ if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
@ -28,7 +24,7 @@ if (!$questionnaire) {
}
$qStmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
SELECT questionID, defaultText, type, orderIndex, isRequired
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$qStmt->execute([':id' => $qnID]);
@ -38,8 +34,6 @@ $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']]);
@ -55,13 +49,9 @@ 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);
@ -114,20 +104,10 @@ 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);
qdb_discard($tmpDb, $lockFp);
json_success([
'questionnaire' => $questionnaire,
'questions' => $questions,
'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);
}
json_success([
"questionnaire" => $questionnaire,
"questions" => $questions,
"clients" => $clients,
]);

View File

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

View File

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

View File

@ -1,70 +0,0 @@
<?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,138 +1,96 @@
<?php
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language', 'language_enabled', 'questionnaire_language'];
$validTypes = ['question', 'answer_option', 'string', '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'])) {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$rows = qdb_load_languages($pdo);
$disables = qdb_questionnaire_language_disables($pdo);
qdb_discard($tmpDb, $lockFp);
json_success([
'languages' => $rows,
'questionnaireLanguageDisables' => $disables,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(["languages" => $rows]);
}
if (!empty($_GET['all'])) {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qnID = $_GET['questionnaireID'] ?? '';
if ($qnID) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$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]);
}
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
$qnRows = $pdo->query("
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
")->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);
$appEntries = qdb_app_ui_string_entries_for_website($pdo);
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
qdb_attach_translation_texts($appEntries, $appTranslations);
$entries = [];
$stringKeys = [];
$questionnaires = [];
$allEntries = $appEntries;
foreach ($dbQuestions as $dbQ) {
$entries[] = ['key' => $dbQ['defaultText'], 'type' => 'question', 'entityId' => $dbQ['questionID']];
foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) {
continue;
$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']];
}
$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;
$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;
}
}
qdb_discard($tmpDb, $lockFp);
json_success([
'languages' => $languages,
'appStrings' => $appEntries,
'questionnaires' => $questionnaires,
'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
foreach (array_keys($stringKeys) as $sk) {
$entries[] = ['key' => $sk, 'type' => 'string', 'entityId' => $sk];
}
}
$qnID = trim((string)($_GET['questionnaireID'] ?? ''));
if ($qnID !== '') {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$translations = [];
$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]);
$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'];
}
$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);
}
$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'];
}
}
$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 ($entries as &$e) {
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
}
unset($e);
qdb_discard($tmpDb, $lockFp);
json_success(["languages" => $languages, "entries" => $entries]);
}
$type = $_GET['type'] ?? '';
@ -141,10 +99,8 @@ case 'GET':
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if ($type === 'question') {
[$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') {
@ -158,62 +114,9 @@ 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]);
} 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);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(["translations" => $rows]);
break;
case 'PUT':
@ -227,64 +130,19 @@ case 'PUT':
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
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]);
}
$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);
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) {
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
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);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
}
@ -296,18 +154,32 @@ case 'PUT':
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 (!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("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_put_translation($pdo, $type, $id, $lang, $text);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save translation', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -321,10 +193,7 @@ case 'DELETE':
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
if ($lang === QDB_SOURCE_LANGUAGE) {
json_error('BAD_REQUEST', 'German (de) cannot be removed', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$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]);
@ -332,8 +201,12 @@ case 'DELETE':
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete language', null, $tmpDb, $lockFp);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
}
@ -344,29 +217,26 @@ 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 (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete translation', null, $tmpDb ?? null, $lockFp ?? null);
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;

View File

@ -1,6 +1,7 @@
<?php
$tokenRec = require_valid_token_web();
$tokenRec = require_valid_token();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
@ -9,7 +10,7 @@ switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
if ($callerRole === 'admin') {
$users = $pdo->query(
@ -52,8 +53,12 @@ case 'GET':
'supervisors' => $supervisors,
'callerRole' => $callerRole,
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
@ -86,7 +91,7 @@ case 'POST':
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
@ -140,7 +145,7 @@ case 'POST':
$svUsername = null;
if ($role === 'coach') {
[$pdo2, $tmp2, $lk2] = qdb_open_read_or_fail();
[$pdo2, $tmp2, $lk2] = qdb_open(false);
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
$svr->execute([':sid' => $supervisorID]);
$svUsername = $svr->fetchColumn() ?: null;
@ -158,209 +163,13 @@ case 'POST':
'mustChangePassword' => $mustChange,
'createdAt' => $now,
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
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);
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);
}
break;
@ -376,7 +185,7 @@ case 'DELETE':
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
$row->execute([':uid' => $targetUserID]);
@ -396,7 +205,7 @@ case 'DELETE':
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
json_error('FORBIDDEN', 'Coach is not under your supervision', 403);
}
}
@ -417,8 +226,13 @@ case 'DELETE':
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
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);
}
break;

106
header_order.json Normal file
View File

@ -0,0 +1,106 @@
[
"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"
]

174
import_questionnaires.php Normal file
View File

@ -0,0 +1,174 @@
<?php
/**
* One-time import: reads the temp-asset JSON files and populates the DB.
* Run from CLI: php import_questionnaires.php
* Or via browser (requires admin token): import_questionnaires.php
*/
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/db_init.php';
$isCli = (php_sapi_name() === 'cli');
if (!$isCli) {
header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token();
require_role(['admin'], $tokenRec);
}
function out(string $msg, bool $isCli): void {
if ($isCli) { echo $msg . "\n"; }
}
$assetsDir = __DIR__ . '/website/temp-assets';
$orderFile = $assetsDir . '/questionnaire_order.json';
$orderData = json_decode(file_get_contents($orderFile), true);
if (!$orderData) {
$err = "Could not read questionnaire_order.json";
if ($isCli) { die($err . "\n"); }
http_response_code(500);
echo json_encode(["error" => $err]);
exit;
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$pdo->beginTransaction();
foreach ($orderData as $orderIdx => $entry) {
$filename = $entry['file'];
$showPoints = (int)($entry['showPoints'] ?? 0);
$condition = json_encode($entry['condition'] ?? new stdClass());
$jsonPath = $assetsDir . '/' . $filename;
$qData = json_decode(file_get_contents($jsonPath), true);
if (!$qData) {
throw new Exception("Could not parse $filename");
}
$questionnaireID = $qData['meta']['id'];
$name = str_replace('_', ' ', preg_replace('/^questionnaire_\d+_/', '', $questionnaireID));
$name = ucwords($name);
out("Importing: $questionnaireID ($name)", $isCli);
$pdo->prepare("INSERT OR REPLACE INTO questionnaire
(questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
->execute([
':id' => $questionnaireID,
':n' => $name,
':v' => '1.0',
':s' => 'active',
':o' => $orderIdx,
':sp' => $showPoints,
':cj' => $condition,
]);
$questions = $qData['questions'] ?? [];
foreach ($questions as $qIdx => $q) {
$qLocalId = $q['id'];
$layout = $q['layout'];
$qKey = $q['question'] ?? '';
$questionID = $questionnaireID . '__' . $qLocalId;
$config = [];
if (isset($q['textKey'])) $config['textKey'] = $q['textKey'];
if (isset($q['textKey1'])) $config['textKey1'] = $q['textKey1'];
if (isset($q['textKey2'])) $config['textKey2'] = $q['textKey2'];
if (isset($q['hint'])) $config['hint'] = $q['hint'];
if (isset($q['hint1'])) $config['hint1'] = $q['hint1'];
if (isset($q['hint2'])) $config['hint2'] = $q['hint2'];
if (isset($q['symptoms'])) $config['symptoms'] = $q['symptoms'];
if (isset($q['range'])) $config['range'] = $q['range'];
if (isset($q['constraints'])) $config['constraints'] = $q['constraints'];
if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection'];
if ($layout === 'string_spinner' && isset($q['options']) && is_array($q['options'])
&& isset($q['options'][0]) && is_string($q['options'][0])) {
$config['options'] = $q['options'];
}
if ($layout === 'value_spinner' && isset($q['options']) && is_array($q['options'])) {
$valueOptions = [];
foreach ($q['options'] as $vo) {
if (is_array($vo) && isset($vo['value'])) {
$valueOptions[] = $vo;
}
}
if ($valueOptions) {
$config['valueOptions'] = $valueOptions;
}
}
$configJson = !empty($config) ? json_encode($config) : '{}';
$pdo->prepare("INSERT OR REPLACE INTO question
(questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qnid, :dt, :ty, :oi, :ir, :cj)")
->execute([
':id' => $questionID,
':qnid' => $questionnaireID,
':dt' => $qKey,
':ty' => $layout,
':oi' => $qIdx,
':ir' => 0,
':cj' => $configJson,
]);
if (isset($q['options']) && is_array($q['options'])) {
$hasObjects = isset($q['options'][0]) && is_array($q['options'][0]);
if ($hasObjects) {
$pointsMap = $q['pointsMap'] ?? [];
foreach ($q['options'] as $optIdx => $opt) {
$optKey = $opt['key'] ?? '';
if (!$optKey) continue;
$nextQId = '';
if (isset($opt['nextQuestionId'])) {
$nextQId = $opt['nextQuestionId'];
}
$pts = (int)($pointsMap[$optKey] ?? 0);
$answerOptionID = $questionID . '__opt_' . $optIdx;
$pdo->prepare("INSERT OR REPLACE INTO answer_option
(answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :dt, :p, :oi, :nq)")
->execute([
':id' => $answerOptionID,
':qid' => $questionID,
':dt' => $optKey,
':p' => $pts,
':oi' => $optIdx,
':nq' => $nextQId,
]);
}
}
}
}
out(" -> " . count($questions) . " questions imported", $isCli);
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
if ($isCli) {
echo "\nImport complete.\n";
} else {
echo json_encode(["success" => true, "message" => "Import complete"]);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
qdb_discard($tmpDb, $lockFp);
if ($isCli) {
die("ERROR: " . $e->getMessage() . "\n");
}
http_response_code(500);
error_log($e->getMessage());
echo json_encode(["error" => "Server error"]);
}

834
index.html Normal file
View File

@ -0,0 +1,834 @@
<!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>

14
lib/QdbHttpResponse.php Normal file
View File

@ -0,0 +1,14 @@
<?php
/**
* Thrown instead of exit() when QDB_TESTING is enabled (PHPUnit integration tests).
*/
final class QdbHttpResponse extends Exception
{
public function __construct(
public readonly array $body,
public readonly int $status,
) {
parent::__construct(json_encode($body, JSON_THROW_ON_ERROR), $status);
}
}

View File

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

View File

@ -1,998 +0,0 @@
<?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;
}

View File

@ -1,306 +0,0 @@
<?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;
}

View File

@ -1,221 +0,0 @@
<?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;
}

View File

@ -1,74 +0,0 @@
<?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;
}

View File

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

View File

@ -1,60 +0,0 @@
<?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;
}

View File

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

View File

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

View File

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

@ -1,412 +0,0 @@
<?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;
}

View File

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

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

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

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

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

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

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

@ -0,0 +1,211 @@
<?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,122 +1,37 @@
<?php
require_once __DIR__ . '/encrypted_payload.php';
require_once __DIR__ . '/QdbHttpResponse.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');
function qdb_test_abort(array $body, int $status): never {
if (defined('QDB_TESTING') && QDB_TESTING) {
throw new QdbHttpResponse($body, $status);
}
$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;
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($body);
exit;
}
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;
qdb_test_abort(["ok" => true, "data" => $data], $status);
}
/**
* @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);
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 json_error(string $code, string $message, int $status = 400): never {
qdb_test_abort(["ok" => false, "error" => ["code" => $code, "message" => $message]], $status);
}
function read_json_body(): array {
$raw = qdb_raw_request_body();
if (isset($GLOBALS['__TEST_JSON_BODY'])) {
$data = json_decode($GLOBALS['__TEST_JSON_BODY'], true);
if (!is_array($data)) {
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
}
return $data;
}
$raw = file_get_contents('php://input');
$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);
}

View File

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

View File

@ -1,114 +0,0 @@
<?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'];
}

View File

@ -1,226 +0,0 @@
<?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 !== ''));
}

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
<?php
/**
* Sanitize user-entered free text before storage (defense in depth; queries use prepared statements).
*/
function sanitize_free_text(mixed $value, int $maxLength = 2000): string {
if (!is_string($value) && !is_numeric($value)) {
return '';
}
$s = trim((string) $value);
if ($s === '') {
return '';
}
$maxLength = max(1, min($maxLength, 10000));
$s = mb_substr($s, 0, $maxLength);
// Control characters (keep tab/newline for multiline answers).
$s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s) ?? '';
$s = str_replace(["\0", '--', '/*', '*/', ';'], '', $s);
$patterns = [
'/\bunion\s+select\b/i',
'/\bselect\s+.+\s+from\b/i',
'/\binsert\s+into\b/i',
'/\bupdate\s+.+\s+set\b/i',
'/\bdelete\s+from\b/i',
'/\bdrop\s+(table|database)\b/i',
'/\bexec(\s|\()/i',
'/\bxp_\w+/i',
'/\bor\s+1\s*=\s*1\b/i',
'/\band\s+1\s*=\s*1\b/i',
"/'\s*or\s+'/i",
'/"\s*or\s+"/i',
'/\bchar\s*\(/i',
'/\bconcat\s*\(/i',
'/\bsleep\s*\(/i',
'/\bbenchmark\s*\(/i',
];
foreach ($patterns as $pattern) {
$s = preg_replace($pattern, '', $s) ?? $s;
}
return trim(preg_replace('/\s{2,}/u', ' ', $s) ?? '');
}
/**
* Returns sanitized text or calls json_error when nothing safe remains.
*/
function require_safe_free_text(mixed $value, string $fieldName, int $maxLength = 2000): string {
$raw = is_string($value) || is_numeric($value) ? trim((string) $value) : '';
if ($raw === '') {
json_error('INVALID_FIELD', "$fieldName is required", 400);
}
$clean = sanitize_free_text($raw, $maxLength);
if ($clean === '') {
json_error('INVALID_FIELD', "$fieldName contains disallowed content", 400);
}
return $clean;
}

76
login.php Normal file
View File

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

View File

236
manage_users.php Normal file
View File

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

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "nat-as-server",
"private": true,
"type": "module",
"scripts": {
"test": "composer test && npm run test:unit:website && npm run test:e2e",
"test:all": "npm run test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:unit:website": "npm run test:unit --prefix website"
},
"devDependencies": {
"@playwright/test": "^1.50.1"
}
}

View File

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

29
playwright.config.ts Normal file
View File

@ -0,0 +1,29 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = 'http://127.0.0.1:8080';
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [
{ name: 'api', testMatch: /api\/.*\.spec\.ts/ },
{ name: 'website', testMatch: /website\/.*\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
],
globalSetup: './e2e/global-setup.js',
webServer: {
command: 'php -S 127.0.0.1:8080 dev-router.php',
url: baseURL + '/website/',
reuseExistingServer: !process.env.CI,
env: {
QDB_MASTER_KEY: 'dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==',
},
},
});

104
questions_en.json Normal file
View File

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

View File

@ -1,270 +0,0 @@
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;

Some files were not shown because too many files have changed in this diff Show More