initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

27
.github/workflows/phpunit.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: PHPUnit
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: json, openssl, pdo, 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

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# Secrets and session data
tokens.jsonl
valid_tokens.txt
.env
*.sh
# Encrypted database, temp files, backups
uploads/*
!uploads/.gitkeep
!uploads/logs/
!uploads/logs/**
# Database lock files (just in case)
uploads/.qdb_lock
uploads/*.lock
# Logs / dumps / exports
*.log
logs/api/*
!logs/api/.gitkeep
*.sqlite
*.db
*.dump
*.bak
*.backup
*.tmp
*.temp
# Node/npm artifacts (if website/js uses npm)
node_modules/
npm-debug.log
yarn.lock
package-lock.json
# PHP composer artifacts (if using composer in future)
tests/runtime/
.phpunit.cache/
vendor/
composer.lock
# IDE / editor
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.vscode/
.idea/
*.code-workspace
# Misc
*.orig
*.rej
*.md
# Ignore browser build artifacts (if any)
website/js/dist/
website/js/build/
# Ignore backups at project root
*.tar
*.tar.gz
*.zip
*.7z

6
api/.htaccess Normal file
View File

@ -0,0 +1,6 @@
RewriteEngine On
# Let the front controller handle everything except itself
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !=/api/index.php
RewriteRule ^(.*)$ index.php [QSA,L]

91
api/index.php Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

21
cli/cleanup_sessions.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* CLI: Remove expired sessions from the database.
* Usage: php cli/cleanup_sessions.php
*/
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';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$stmt = $pdo->prepare("DELETE FROM session WHERE expiresAt < :now");
$stmt->execute([':now' => time()]);
$removed = $stmt->rowCount();
qdb_save($tmpDb, $lockFp);
echo "Removed $removed expired session(s).\n";

156
cli/diagnose_db.php Normal file
View File

@ -0,0 +1,156 @@
<?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";
exit(1);
}
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";
$dbPath = $argv[1] ?? QDB_PATH;
if ($dbPath !== QDB_PATH && !is_file($dbPath)) {
echo "File not found: $dbPath\n";
exit(1);
}
if ($dbPath !== QDB_PATH) {
echo "Note: probing alternate file (app still uses " . QDB_PATH . " at runtime)\n";
}
$uploads = dirname($dbPath);
echo "uploads dir: $uploads\n";
echo " exists: " . (is_dir($uploads) ? 'yes' : 'no') . "\n";
echo " writable: " . (is_writable($uploads) ? 'yes' : 'NO — migrations cannot save') . "\n";
echo "DB file: $dbPath\n";
if (is_file($dbPath)) {
$mtime = filemtime($dbPath);
echo " exists: yes (" . filesize($dbPath) . " bytes)\n";
echo " modified: " . ($mtime ? date('Y-m-d H:i:s T', $mtime) : '?') . "\n";
} else {
echo " exists: no (will create on first write)\n";
}
$backupDir = $uploads . '/backups';
if (is_dir($backupDir)) {
$backups = glob($backupDir . '/questionnaire_database.*') ?: [];
rsort($backups);
echo "Backups in uploads/backups: " . count($backups) . "\n";
foreach (array_slice($backups, 0, 5) as $bp) {
$mt = filemtime($bp);
echo " - " . basename($bp) . ' (' . filesize($bp) . " bytes, " . ($mt ? date('Y-m-d H:i:s', $mt) : '?') . ")\n";
}
} else {
echo "Backups: none (uploads/backups missing)\n";
}
if (!is_file($dbPath)) {
exit(0);
}
$candidates = [
'current .env (get_master_key_bytes)' => get_master_key_bytes(),
'legacy fallback (pre-April 2026 default)' => str_pad('12345678901234567890123456789012', 32, "\0"),
'raw first 32 chars of QDB_MASTER_KEY' => str_pad(substr($keyEnv, 0, 32), 32, "\0"),
];
if ($b64 !== false && strlen($b64) > 0) {
$candidates['base64-decoded .env only'] = str_pad(substr($b64, 0, 32), 32, "\0");
}
echo "\nDecrypt probe (which key matches questionnaire_database?):\n";
$matchedLabel = null;
$matchedKey = null;
$appKey = get_master_key_bytes();
foreach ($candidates as $label => $keyBytes) {
$probe = qdb_probe_decrypt($dbPath, $keyBytes);
if ($probe['ok']) {
echo " MATCH: $label\n";
if ($matchedLabel === null) {
$matchedLabel = $label;
$matchedKey = $keyBytes;
}
} else {
echo " no: $label\n";
}
}
if ($matchedLabel === null) {
echo "\nNo known key opened the file. Likely causes:\n";
echo " - DB was encrypted with a different QDB_MASTER_KEY than in .env now\n";
echo " - File was corrupted (e.g. interrupted save); try an older uploads/backups copy\n";
echo " - Wrong DB path (another install under /var/www/...)\n";
echo "\nProbe other copies, e.g.:\n";
echo " php cli/diagnose_db.php /var/www/html/qdb/uploads/questionnaire_database\n";
echo "\nWiping is NOT required for schema updates. Only wipe if you accept data loss\n";
echo "and run: mv uploads/questionnaire_database uploads/questionnaire_database.bak\n";
echo "then: php seed_admin.php <user> <pass>\n";
exit(1);
}
$dbMatchesAppKey = $matchedKey !== null && hash_equals($matchedKey, $appKey);
if ($dbPath !== QDB_PATH && $dbMatchesAppKey) {
echo "\nThis file decrypts with your .env. Restore it into the app uploads dir:\n";
echo " cp " . escapeshellarg($dbPath) . ' ' . escapeshellarg(QDB_PATH) . "\n";
echo " chown www-data:www-data " . escapeshellarg(QDB_PATH) . "\n";
exit(0);
}
if (!$dbMatchesAppKey) {
echo "\nThe DB does NOT match get_master_key_bytes() but matches: $matchedLabel\n";
echo "Fix: align QDB_MASTER_KEY in .env and php-fpm pool env, then re-run diagnose.\n";
exit(1);
}
if ($dbPath !== QDB_PATH) {
echo "\nDecrypt OK for alternate path. Run without arguments after copying into uploads/.\n";
exit(0);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$version = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
$users = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$hasSession = qdb_table_exists($pdo, 'session');
qdb_discard($tmpDb, $lockFp);
echo "\nOK: full open + migrations succeeded.\n";
echo " PRAGMA user_version: $version (expected " . QDB_VERSION . ")\n";
echo " users: $users\n";
echo " session table: " . ($hasSession ? 'yes' : 'MISSING') . "\n";
exit(0);
} catch (Throwable $e) {
echo "\nDecrypt OK but qdb_open failed: " . $e->getMessage() . "\n";
exit(1);
}

77
cli/diagnose_login.php Normal file
View File

@ -0,0 +1,77 @@
<?php
/**
* CLI: Reproduce login DB + token_add steps (same code path as auth/login).
* Usage: sudo -u www-data php cli/diagnose_login.php <username> <password>
*/
if (php_sapi_name() !== 'cli') {
exit(1);
}
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
$username = $argv[1] ?? '';
$password = $argv[2] ?? '';
if ($username === '' || $password === '') {
fwrite(STDERR, "Usage: php cli/diagnose_login.php <username> <password>\n");
fwrite(STDERR, "Run as www-data to match php-fpm: sudo -u www-data php cli/diagnose_login.php ...\n");
exit(1);
}
echo 'User: ' . (function_exists('posix_getpwuid') ? (posix_getpwuid(posix_geteuid())['name'] ?? '?') : '?') . "\n";
echo 'QDB_MASTER_KEY env length: ' . strlen(getenv('QDB_MASTER_KEY') ?: '') . "\n";
echo 'Derived key sha256: ' . hash('sha256', get_master_key_bytes()) . "\n\n";
try {
echo "1) qdb_open(read-only)... ";
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
echo "OK\n";
echo "2) lookup user... ";
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID, mustChangePassword FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
qdb_discard($tmpDb, $lockFp);
echo "FAIL — user not found\n";
exit(1);
}
echo "OK (role={$user['role']}, mustChange={$user['mustChangePassword']})\n";
echo "3) password_verify... ";
if (!password_verify($password, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp);
echo "FAIL — wrong password\n";
exit(1);
}
echo "OK\n";
qdb_discard($tmpDb, $lockFp);
echo "4) token_add (writable save, same as login)... ";
$token = bin2hex(random_bytes(32));
token_add($token, 120, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
'temp' => (int)$user['mustChangePassword'] === 1,
]);
echo "OK\n";
echo "5) token_get_record... ";
$rec = token_get_record($token);
if (!$rec) {
echo "FAIL — session not readable after save\n";
exit(1);
}
echo "OK\n";
token_revoke($token);
echo "\nAll login steps succeeded. If the website still fails, check nginx routing (curl below).\n";
} catch (Throwable $e) {
echo "FAIL\n " . $e->getMessage() . "\n";
echo " " . $e->getFile() . ':' . $e->getLine() . "\n";
exit(1);
}

2398
common.php Normal file

File diff suppressed because it is too large Load Diff

25
composer.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "nat-as/ressourcenbarometer-server",
"description": "Encrypted SQLite questionnaire API",
"type": "project",
"require": {
"php": ">=8.2",
"ext-json": "*",
"ext-openssl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit",
"test:coverage": "phpunit --coverage-text",
"test:ci": "php tests/check-coverage.php 40"
}
}

BIN
composer.phar Normal file

Binary file not shown.

298
data/app_ui_strings.json Normal file
View File

@ -0,0 +1,298 @@
{
"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",
"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_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",
"points",
"previous",
"question",
"questions_filled",
"refresh",
"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_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_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": "NAT-AS Ressourcenbarometer",
"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.",
"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_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 6 Zeichen",
"passwords_dont_match": "Passwörter stimmen nicht überein",
"please_client_code": "Bitte Klienten Code eingeben",
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
"points": "Punkte",
"previous": "Zurück",
"question": "Frage",
"questions_filled": "Antworten",
"refresh": "Aktualisieren",
"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_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 · {clients} Klienten",
"upload_success_message": "Hochladen: {count} erledigt.",
"upload_success_questionnaires": "{count} Fragebogen/Fragebögen",
"upload_success_scoring_reviews": "{count} Punkteprüfung(en)",
"upload_success_sync_next": "Nach diesem Upload startet die Synchronisierung automatisch.",
"upload_success_title": "Upload abgeschlossen",
"username_hint": "Benutzername",
"year": "Jahr"
}
}

View File

@ -0,0 +1,190 @@
Ägypten
Äquatorialguinea
Äthiopien
Afghanistan
Albanien
Algerien
Andorra
Angola
Antigua und Barbuda
Argentinien
Armenien
Aserbaidschan
Australien
Bahamas
Bahrain
Bangladesch
Barbados
Belgien
Belize
Benin
Bhutan
Bolivien
Bosnien und Herzegowina
Botswana
Brasilien
Brunei
Bulgarien
Burkina Faso
Burundi
Cabo Verde
Cambodia
Chile
China
Costa Rica
Dänemark
Deutschland
Djibouti
Dominica
Dominikanische Republik
Ecuador
El Salvador
Eritrea
Estland
Eswatini
Fiji
Finnland
Frankreich
Gabon
Gambia
Georgien
Ghana
Grenada
Griechenland
Guatemala
Guinea
Guinea-Bissau
Guyana
Haiti
Honduras
Indien
Indonesien
Irak
Iran
Irland
Island
Israel
Italien
Jamaika
Japan
Jordanien
Kamerun
Kanada
Kasachstan
Kenia
Kiribati
Kolumbien
Komoren
Kongo (Dem. Rep.)
Kongo (Rep.)
Korea (Nord)
Korea (Süd)
Kroatien
Kuba
Kuwait
Kyrgyzstan
Laos
Latvia
Lebanon
Lesotho
Liberia
Libyen
Liechtenstein
Litauen
Luxemburg
Madagaskar
Malawi
Malaysia
Maldiven
Mali
Malta
Marokko
Marshallinseln
Mauritanien
Mauritius
Mexiko
Mikronesien
Moldawien
Monaco
Mongolei
Montenegro
Mozambique
Namibia
Nauru
Nepal
Nicaragua
Niger
Nigeria
Nordmazedonien
Norwegen
Österreich
Oman
Pakistan
Palau
Panama
Papua-Neuguinea
Paraguay
Peru
Philippinen
Polen
Portugal
Ruanda
Rumänien
Russland
Salomonen
Sambia
Samoa
San Marino
Sao Tome und Principe
Saudi-Arabien
Schweden
Schweiz
Senegal
Serbien
Seychellen
Sierra Leone
Simbabwe
Singapur
Slowakei
Slowenien
Spanien
Sri Lanka
St. Kitts und Nevis
St. Lucia
St. Vincent und die Grenadinen
Sudan
Südafrika
Südkorea
Südsudan
Suriname
Syrien
São Tomé und Príncipe
Tadschikistan
Taiwan
Tansania
Togo
Tonga
Trinidad und Tobago
Tschad
Tschechien
Türkei
Tunisien
Turkmenistan
Tuvalu
Uganda
Ukraine
Ungarn
Uruguay
Usbekistan
Vanuatu
Venezuela
Vereinigte Arabische Emirate
Vereinigte Staaten
Vereinigtes Königreich
Vietnam
Wallis und Futuna
Westjordanland
Westsahara
Yemen
Zentralafrikanische Republik
Zypern

568
db_init.php Normal file
View File

@ -0,0 +1,568 @@
<?php
// /var/www/html/db_init.php
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
require_once __DIR__ . '/common.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', 12);
function qdb_table_exists(PDO $pdo, string $table): bool {
$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 {
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
if (($col['name'] ?? '') === $column) {
return true;
}
}
return false;
}
/**
* Apply incremental schema fixes. Returns true if the temp 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)
)
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_qn ON questionnaire_submission(clientCode, questionnaireID)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_time ON 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)
)
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_score_rule_question ON 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,
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
)
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile ON 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 = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
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 = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
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 = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
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 = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
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 = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
if ($currentVersion < QDB_VERSION) {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->exec($schemaSql);
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''");
}
$pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';');
$pdo->exec('PRAGMA foreign_keys = ON;');
$changed = true;
}
return $changed;
}
/**
* Exclusive lock with short retries (non-blocking attempts). Avoids hung PHP workers
* when two writes overlap (app upload + website edit).
*/
function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sleepMicros = 75000): void
{
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
if (flock($lockFp, LOCK_EX | LOCK_NB)) {
return;
}
if ($attempt < $maxAttempts - 1) {
usleep($sleepMicros);
}
}
throw new Exception('Could not acquire lock');
}
function qdb_open_writable_lock()
{
$lockFp = fopen(QDB_LOCK, 'c');
if ($lockFp === false) {
throw new Exception('Could not open lock file');
}
qdb_flock_exclusive_with_retry($lockFp);
return $lockFp;
}
function qdb_acquire_lock() {
return qdb_open_writable_lock();
}
/**
* Decrypt the master DB file into a temp SQLite file, or create a fresh one
* from schema.sql if no DB exists yet.
*
* Returns [PDO $pdo, string $tmpPath, resource|null $lockFp].
* If $writable is true, an exclusive lock is held -- caller MUST call
* qdb_save() or qdb_discard() afterwards.
*/
function qdb_open(bool $writable = false): array {
if (!$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");
}
}
$lockFp = null;
if ($writable) {
$lockFp = qdb_open_writable_lock();
}
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_');
$masterKey = get_master_key_bytes();
$sql = file_get_contents(QDB_SCHEMA);
if ($sql === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not read schema.sql");
}
if (file_exists($dbPath) && is_file($dbPath)) {
$storedEnc = @file_get_contents($dbPath);
if ($storedEnc === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not read stored DB");
}
try {
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
} catch (Throwable $e) {
error_log(sprintf(
'qdb_open decrypt failed: path=%s realpath=%s enc_bytes=%d key_sha=%s sapi=%s env_readable=%s common=%s',
$dbPath,
realpath($dbPath) ?: 'none',
strlen($storedEnc),
hash('sha256', $masterKey),
PHP_SAPI,
is_readable(__DIR__ . '/.env') ? 'yes' : 'no',
realpath(__DIR__ . '/common.php') ?: __DIR__ . '/common.php'
));
throw $e;
}
if (file_put_contents($tmpDb, $decrypted) === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); }
throw new Exception("Could not write temp DB");
}
} else {
$pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec($sql);
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
$pdo = null;
}
$pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("PRAGMA foreign_keys = ON;");
$schemaChanged = qdb_apply_migrations($pdo, $sql);
// Read-only opens discard the temp file; persist one-off migrations to the master DB.
if ($schemaChanged && !$writable) {
$migrateLock = qdb_acquire_lock();
try {
qdb_save($tmpDb, $migrateLock);
} catch (Throwable $e) {
@unlink($tmpDb);
flock($migrateLock, LOCK_UN);
fclose($migrateLock);
throw $e;
}
return qdb_open(false);
}
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 {
$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");
$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");
}
if (!@rename($tmpEncrypted, $dbPath)) {
if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) {
throw new Exception("Could not save encrypted DB (rename/copy failed)");
}
}
@chmod($dbPath, 0644);
@unlink($tmpDb);
if ($lockFp) {
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 (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) {
return;
}
@unlink($tmpDb);
if ($lockFp) {
@flock($lockFp, LOCK_UN);
@fclose($lockFp);
}
}
/**
* Open a read-only DB connection; on failure logs and returns a JSON error response.
*
* @return array{0: PDO, 1: string, 2: resource|null}|null
*/
/**
* @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');
}
}

21
handlers/activity_log.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* Admin: read API activity log (app sync + data changes only).
*/
require_once __DIR__ . '/../lib/api_log.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$date = trim((string)($_GET['date'] ?? date('Y-m-d')));
$activity = trim((string)($_GET['activity'] ?? ''));
$limit = (int)($_GET['limit'] ?? 200);
$errorsOnly = !empty($_GET['errorsOnly']) || $activity === 'errors';
json_success(qdb_api_log_read_entries($date, $activity, $limit, $errorsOnly));

51
handlers/analytics.php Normal file
View File

@ -0,0 +1,51 @@
<?php
$tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
require_once __DIR__ . '/../lib/analytics.php';
switch ($method) {
case 'GET':
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if (!empty($_GET['overview'])) {
$data = qdb_analytics_overview($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success($data);
}
if (!empty($_GET['staleClients'])) {
$clients = qdb_analytics_stale_clients($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success(['clients' => $clients]);
}
qdb_discard($tmpDb, $lockFp);
json_error('BAD_REQUEST', 'Unknown query', 400);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Analytics', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
$body = read_json_body();
$clientCode = trim((string)($body['clientCode'] ?? ''));
$note = (string)($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);
}

217
handlers/answer_options.php Normal file
View File

@ -0,0 +1,217 @@
<?php
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
$qID = $_GET['questionID'] ?? '';
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);
}
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);
}
$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();
try {
$chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id');
$chk->execute([':id' => $qID]);
$qnID = $chk->fetchColumn();
if (!$qnID) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$qnID = (string)$qnID;
if ($order === 0) {
$max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id');
$max->execute([':id' => $qID]);
$order = (int)$max->fetchColumn();
}
qdb_assert_unique_option_key($pdo, $qID, $optKey);
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_added');
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev,
'answerOption' => [
'answerOptionID' => $id,
'questionID' => $qID,
'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $text,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
'translations' => [],
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['answerOptionID'])) {
json_error('MISSING_FIELDS', 'answerOptionID is required', 400);
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
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);
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);
$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');
}
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1),
'answerOption' => [
'answerOptionID' => $id,
'questionID' => $row['questionID'],
'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $labelGerman,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['answerOptionID'])) {
json_error('MISSING_FIELDS', 'answerOptionID is required', 400);
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
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->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;
}
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
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();
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);
json_success(['reordered' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

View File

@ -0,0 +1,626 @@
<?php
/**
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
* 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 -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
*/
// Public app UI catalog for the login screen (no token, no PII).
if ($method === 'GET' && !empty($_GET['translations'])) {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
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);
}
}
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']);
$clientCode = trim($body['clientCode']);
$answers = $body['answers'];
if (!is_array($answers)) {
json_error('INVALID_FIELD', 'answers must be an array', 400);
}
$startedAt = isset($body['startedAt']) ? (int)$body['startedAt'] : null;
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qnStmt = $pdo->prepare(
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire WHERE questionnaireID = :id'
);
$qnStmt->execute([':id' => $qnID]);
$qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$currentRev = max(1, (int)$qnRow['structureRevision']);
$submitRev = max(1, (int)($body['structureRevision'] ?? 1));
if ($submitRev > $currentRev) {
qdb_discard($tmpDb, $lockFp);
json_error('STRUCTURE_REVISION_NEWER', 'App structure revision is newer than server', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
$clientRow = $clStmt->fetch(PDO::FETCH_ASSOC);
if (!$clientRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$assignedByCoach = ($tokenRec['role'] === 'coach')
? ($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;
}
$maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest);
$shortIdMap = $maps['shortIdMap'];
$shortIdToType = $maps['shortIdToType'];
$optionMap = $maps['optionMap'];
$symptomParentMap = $maps['symptomParentMap'];
$freeTextMaxLen = $maps['freeTextMaxLen'];
require_once __DIR__ . '/../lib/app_submit_validate.php';
$validationErrors = qdb_validate_app_submit_payload(
$pdo,
$qnID,
$answers,
$shortIdMap,
$shortIdToType,
$symptomParentMap,
$optionMap,
$submitManifest
);
if ($validationErrors !== []) {
qdb_discard($tmpDb, $lockFp);
json_error(
'VALIDATION_FAILED',
'Some answers are invalid or missing',
400,
['errors' => $validationErrors]
);
}
$pdo->beginTransaction();
$glassByParent = [];
$submittedQuestionIds = [];
$answerInsert = $pdo->prepare("
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
ON CONFLICT(clientCode, questionID) DO UPDATE SET
answerOptionID = excluded.answerOptionID,
freeTextValue = excluded.freeTextValue,
numericValue = excluded.numericValue,
answeredAt = excluded.answeredAt
");
$answerDelete = $pdo->prepare(
'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
require_once __DIR__ . '/../lib/app_answers.php';
$answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap);
foreach ($answers as $a) {
$shortId = trim($a['questionID'] ?? '');
if ($shortId === '') {
continue;
}
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
// Glass-scale symptoms use string keys (not question localIds).
if (isset($symptomParentMap[$shortId])) {
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
if ($label !== '') {
$parentId = $symptomParentMap[$shortId];
$glassByParent[$parentId][$shortId] = $label;
}
continue;
}
// Resolve short ID to full questionID
$fullQID = $shortIdMap[$shortId] ?? null;
if ($fullQID === null) {
continue;
}
$answerOptionID = null;
$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 === '') {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
}
// Resolve option key to answerOptionID (points computed by scoring engine after save)
$optionKey = $a['answerOptionKey'] ?? null;
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
$opt = $optionMap[$fullQID][(string)$optionKey];
$answerOptionID = $opt['answerOptionID'];
}
$hasValue = $answerOptionID !== null
|| ($freeTextValue !== null && $freeTextValue !== '')
|| $numericValue !== null;
if (!$hasValue) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
$answerInsert->execute([
':cc' => $clientCode,
':qid' => $fullQID,
':aoid' => $answerOptionID,
':ftv' => $freeTextValue,
':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("
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp)
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET
assignedByCoach = excluded.assignedByCoach,
status = 'completed',
startedAt = COALESCE(excluded.startedAt, startedAt),
completedAt = excluded.completedAt,
sumPoints = excluded.sumPoints
")->execute([
':cc' => $clientCode,
':qn' => $qnID,
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
':sa' => $startedAt,
':ca' => $completedAt,
':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,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
}
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? '';
$fetchAnswers = $_GET['answers'] ?? '';
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
$fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
$fetchScoringReview = $_GET['scoringReview'] ?? '';
$clientCode = trim($_GET['clientCode'] ?? '');
if ($fetchScoringProfiles) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$profiles = array_values(array_filter(
qdb_list_scoring_profiles($pdo),
static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1
));
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']);
}
if ($fetchScoringReview) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$coachID = $tokenRec['entityID'] ?? '';
if ($clientCode !== '') {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$clientCodes = [$clientCode];
} else {
$stmt = $pdo->prepare(
'SELECT clientCode FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode'
);
$stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
$clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
if ($fetchAnswersBulk) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/app_answers.php';
$clients = qdb_export_app_bulk_answers_for_coach($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
if ($fetchAnswers) {
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
if ($clientCode === '') {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_PARAM', 'clientCode query param required', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/app_answers.php';
$questionnaires = qdb_export_app_client_answers_bundle($pdo, $clientCode);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive([
'clientCode' => $clientCode,
'questionnaires' => $questionnaires,
], $tokenRec['_token']);
}
if ($fetchClients) {
require_role(['coach'], $tokenRec);
$coachID = $tokenRec['entityID'] ?? '';
$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);
$completions = [];
if (!empty($clientCodes)) {
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
$stmt2 = $pdo->prepare("
SELECT clientCode, questionnaireID, sumPoints, completedAt
FROM completed_questionnaire
WHERE clientCode IN ($placeholders)
");
$stmt2->execute($clientCodes);
foreach ($stmt2->fetchAll(PDO::FETCH_ASSOC) as $row) {
$completions[$row['clientCode']][] = [
'questionnaireID' => $row['questionnaireID'],
'sumPoints' => (int) $row['sumPoints'],
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
];
}
}
$clients = array_map(function ($code) use ($completions) {
return [
'clientCode' => $code,
'completedQuestionnaires' => $completions[$code] ?? [],
];
}, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
}
if ($qnID) {
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
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->execute([':id' => $qnID]);
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questions = [];
foreach ($dbQuestions as $dbQ) {
$localId = $dbQ['questionID'];
$parts = explode('__', $localId);
$shortId = end($parts);
$layout = $dbQ['type'];
$config = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$q = [
'id' => $shortId,
'layout' => $layout,
'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
];
if ($qKey !== '' && !empty($config['noteBefore'])) {
$q['noteBeforeKey'] = qdb_note_before_key($qKey);
}
if ($qKey !== '' && !empty($config['noteAfter'])) {
$q['noteAfterKey'] = qdb_note_after_key($qKey);
}
if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
if (isset($config['hint'])) $q['hint'] = $config['hint'];
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if ($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['minSelection'])) $q['minSelection'] = $config['minSelection'];
if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength'];
if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId'];
if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId'];
if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey'];
if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options'];
}
if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions'];
}
if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) {
require_once __DIR__ . '/../lib/scoring.php';
$ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']);
if ($ruleMap !== []) {
$q['scoreRuleMap'] = $ruleMap;
}
}
$ao = $pdo->prepare(
'SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
ORDER BY orderIndex'
);
$ao->execute([':qid' => $dbQ['questionID']]);
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
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'];
}
// DB answer_option rows are for choice layouts only — do not overwrite string/value spinners.
if (in_array($layout, ['radio_question', 'multi_check_box_question', 'glass_scale_question'], true)) {
$q['options'] = $optionsArr;
$q['pointsMap'] = $pointsMap;
}
}
$questions[] = $q;
}
qdb_discard($tmpDb, $lockFp);
json_success([
'meta' => ['id' => $qnID],
'structureRevision' => $structureRevision,
'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]);
}
// 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
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$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(),
];
$cat = trim($r['categoryKey'] ?? '');
if ($cat !== '') {
$item['categoryKey'] = $cat;
}
$list[] = $item;
}
qdb_discard($tmpDb, $lockFp);
json_success($list);

99
handlers/assignments.php Normal file
View File

@ -0,0 +1,99 @@
<?php
$tokenRec = require_valid_token_web();
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
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, 'cl');
$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);
json_success(['coaches' => $coaches, 'clients' => $clients]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'assignments', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$clientCodes = $body['clientCodes'] ?? [];
$coachID = trim($body['coachID'] ?? '');
if (empty($clientCodes) || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCodes and coachID are required', 400);
}
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
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);
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
}
[$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);
json_success(['assigned' => $count]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'assignments', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

286
handlers/auth.php Normal file
View File

@ -0,0 +1,286 @@
<?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':
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$body = read_json_body();
$username = trim($body['username'] ?? '');
$password = (string)($body['password'] ?? '');
if ($username === '' || $password === '') {
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();
$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 || !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), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
'temp' => true,
]);
json_success([
'mustChangePassword' => true,
'token' => $tempToken,
'user' => $username,
'role' => $user['role'],
]);
}
$token = bin2hex(random_bytes(32));
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
$loginData = [
'token' => $token,
'user' => $username,
'role' => $user['role'],
];
if ($assignedClients !== []) {
$loginData['clientsPayload'] = qdb_sensitive_envelope(
json_encode(['clients' => $assignedClients], JSON_UNESCAPED_UNICODE),
$token
);
}
json_success($loginData);
} catch (Throwable $e) {
qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'auth/change-password':
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$bearerToken = get_bearer_token();
if (!$bearerToken) {
json_error('UNAUTHORIZED', 'Bearer token required', 401);
}
$tokenRec = token_get_record($bearerToken);
if (!$tokenRec) {
json_error('FORBIDDEN', 'Invalid or expired token', 403);
}
$body = read_json_body();
$username = trim($body['username'] ?? '');
$oldPassword = (string)($body['old_password'] ?? '');
$newPassword = (string)($body['new_password'] ?? '');
if ($username === '' || $oldPassword === '' || $newPassword === '') {
json_error('MISSING_FIELDS', 'username, old_password, and new_password are required', 400);
}
if (strlen($newPassword) < 6) {
json_error('PASSWORD_TOO_SHORT', 'New password must be at least 6 characters', 400);
}
if (($tokenRec['userID'] ?? '') === '') {
json_error('FORBIDDEN', 'Token not associated with a user', 403);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$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);
json_error('INVALID_CREDENTIALS', 'Invalid credentials', 401);
}
if ($user['userID'] !== $tokenRec['userID']) {
qdb_discard($tmpDb, $lockFp);
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);
}
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$pdo->prepare(
"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
$newToken = bin2hex(random_bytes(32));
token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
json_success([
'token' => $newToken,
'user' => $username,
'role' => $user['role'],
]);
} 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);
}
break;
default:
json_error('NOT_FOUND', 'Unknown auth route', 404);
}

33
handlers/backup.php Normal file
View File

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

239
handlers/clients.php Normal file
View File

@ -0,0 +1,239 @@
<?php
$tokenRec = require_valid_token_web();
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$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);
$stmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
co.username AS coachUsername,
CASE WHEN EXISTS (
SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode
) OR EXISTS (
SELECT 1 FROM questionnaire_submission qs WHERE qs.clientCode = cl.clientCode
) THEN 1 ELSE 0 END AS hasResponseData
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.archived ASC, hasResponseData DESC, cl.clientCode ASC"
);
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../lib/submissions.php';
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
foreach ($clients as &$client) {
$client['scoringProfiles'] = qdb_client_scoring_dots_for_client(
$client['clientCode'],
$scoringSummary
);
}
unset($client);
qdb_discard($tmpDb, $lockFp);
json_success([
'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
$coachID = trim($body['coachID'] ?? '');
if ($clientCode === '' || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
// Validate coach exists and caller is allowed to assign to them
if ($callerRole === 'supervisor') {
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chkCoach->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
} else {
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chkCoach->execute([':cid' => $coachID]);
}
if (!$chkCoach->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
}
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
$chk->execute([':cc' => $clientCode]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
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]);
qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
} 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);
}
break;
case 'DELETE':
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
if ($clientCode === '') {
json_error('MISSING_FIELDS', 'clientCode is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
// Verify the client exists and the caller can see it (RBAC)
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
$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/submissions.php';
$pdo->beginTransaction();
qdb_delete_client_response_data($pdo, [$clientCode]);
$pdo->prepare('DELETE FROM client WHERE clientCode = :cc')
->execute([':cc' => $clientCode]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

29
handlers/coaches.php Normal file
View File

@ -0,0 +1,29 @@
<?php
$tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
require_once __DIR__ . '/../lib/analytics.php';
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$coachID = trim((string)($_GET['coachID'] ?? ''));
if ($coachID !== '' && !empty($_GET['recent'])) {
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
$offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0;
$recent = qdb_coach_recent_submissions($pdo, $tokenRec, $coachID, $limit, $offset);
qdb_discard($tmpDb, $lockFp);
json_success($recent);
}
$coaches = qdb_coach_activity_list($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load coaches', null, $tmpDb ?? null, $lockFp ?? null);
}

53
handlers/dev.php Normal file
View File

@ -0,0 +1,53 @@
<?php
/**
* Admin-only dev fixture import (local / test environments).
*/
require_once __DIR__ . '/../lib/dev_fixture.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$body = read_json_body();
$action = trim((string)($body['action'] ?? 'import'));
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
if ($action === 'remove') {
$result = qdb_remove_dev_test_data($pdo, [
'usernamePrefix' => $body['usernamePrefix'] ?? 'dev_',
'clientCodePrefix' => $body['clientCodePrefix'] ?? 'DEV-CL-',
]);
qdb_save($tmpDb, $lockFp);
json_success($result);
}
if ($action === 'wipeExceptAdmins') {
$result = qdb_wipe_all_data_except_admins($pdo);
qdb_save($tmpDb, $lockFp);
json_success($result);
}
$fixture = $body['fixture'] ?? null;
if (!is_array($fixture)) {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_FIELDS', 'fixture object is required for import', 400);
}
$result = qdb_import_dev_fixture($pdo, $fixture);
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
$labels = [
'remove' => 'Remove dev data',
'wipeExceptAdmins' => 'Wipe database',
'import' => 'Import dev fixture',
];
$label = $labels[$action ?? 'import'] ?? 'Dev fixture';
qdb_handler_fail($e, $label, $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}

111
handlers/export.php Normal file
View File

@ -0,0 +1,111 @@
<?php
require_once __DIR__ . '/../lib/submissions.php';
$tokenRec = require_valid_token_web();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
if (!empty($_GET['bundle'])) {
require_role(['admin', 'supervisor'], $tokenRec);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$bundle = qdb_export_all_questionnaires_bundle($pdo);
qdb_discard($tmpDb, $lockFp);
$filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json';
qdb_http_download(
json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
[
'Content-Type' => 'application/json; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Export questionnaires bundle', null, $tmpDb ?? null, $lockFp ?? null);
}
}
if (!empty($_GET['exportAll'])) {
require_role(['admin'], $tokenRec);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$allVersions = !empty($_GET['allVersions']);
$zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions);
qdb_discard($tmpDb, $lockFp);
$label = $allVersions ? 'all_versions' : 'current';
$filename = 'responses_export_' . $label . '_' . date('Y-m-d_His') . '.zip';
$zipBody = (string)file_get_contents($zipPath);
@unlink($zipPath);
qdb_http_download($zipBody, [
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string)strlen($zipBody),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Export responses ZIP', null, $tmpDb ?? null, $lockFp ?? null);
}
}
$qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
$allVersions = !empty($_GET['allVersions']);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
$questions = $ctx['questions'];
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
if ($allVersions) {
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
qdb_discard($tmpDb, $lockFp);
$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($resultColumns)),
[
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
}
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
qdb_discard($tmpDb, $lockFp);
$safeName = qdb_export_safe_basename($questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
qdb_http_download(
qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($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);
}

18
handlers/logout.php Normal file
View File

@ -0,0 +1,18 @@
<?php
if ($method !== 'DELETE' && $method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$token = get_bearer_token();
if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
}
try {
token_revoke($token);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Logout');
}
json_success(['loggedOut' => true]);

244
handlers/questionnaires.php Normal file
View File

@ -0,0 +1,244 @@
<?php
$tokenRec = require_valid_token_web();
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);
}
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 {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists);
$pdo->exec('PRAGMA foreign_keys = ON;');
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);
}
$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_write_or_fail();
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)")
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version,
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
'conditionJson' => $condJson, 'questionCount' => 0,
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Create questionnaire', null, $tmpDb, $lockFp);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID'])) {
json_error('QUESTIONNAIRE_ID_REQUIRED', 'questionnaireID is required', 400);
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
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);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$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'];
$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
WHERE questionnaireID = :id")
->execute([':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update questionnaire', null, $tmpDb, $lockFp);
}
break;
case 'DELETE':
require_role(['admin'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID'])) {
json_error('QUESTIONNAIRE_ID_REQUIRED', 'questionnaireID is required', 400);
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$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) {
$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);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete questionnaire', $pdo, $tmpDb, $lockFp);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

357
handlers/questions.php Normal file
View File

@ -0,0 +1,357 @@
<?php
require_once __DIR__ . '/../lib/scoring.php';
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
$qnID = $_GET['questionnaireID'] ?? '';
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"
);
$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'
);
$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);
}
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);
}
$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();
try {
$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);
}
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();
}
$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');
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev,
'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) : [],
],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID'])) {
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
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);
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);
}
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'question', $id, $text);
qdb_sync_question_note_strings($pdo, $questionKey, $config);
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
$newRev = null;
if ($structuralChange) {
$newRev = qdb_bump_structure_revision($pdo, (string)$row['questionnaireID'], 'question_updated');
}
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
'question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'defaultText' => $text, 'questionKey' => $questionKey,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson,
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID'])) {
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
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->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;
}
$pdo->exec('PRAGMA foreign_keys = OFF;');
$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_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]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([
'deleted' => true,
'retired' => false,
'structureRevision' => $newRev,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
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);
}
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]);
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Reorder questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

124
handlers/results.php Normal file
View File

@ -0,0 +1,124 @@
<?php
$tokenRec = require_valid_token_web();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$qnID = $_GET['questionnaireID'] ?? '';
$clientCode = $_GET['clientCode'] ?? '';
if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$qStmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
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'];
$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']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
}
}
unset($q);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause
";
$params = array_merge([':qnid' => $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);
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);
json_success([
'questionnaire' => $questionnaire,
'questions' => $questions,
'clients' => $clients,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);
}

View File

@ -0,0 +1,203 @@
<?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);
}
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, createdAt, updatedAt)
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :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'],
':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);
}
$pdo->prepare(
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
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'],
':ua' => time(),
':id' => $profileID,
]);
if ($members !== null) {
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
}
qdb_recompute_all_profiles_after_change($pdo, $profileID);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $profileID]);
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
/**
* @return list<array{questionnaireID: string, weight: float, orderIndex: int}>
*/
function qdb_parse_profile_questionnaires(array $rows): array {
$out = [];
$idx = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$qnID = trim((string)($row['questionnaireID'] ?? ''));
if ($qnID === '') {
continue;
}
$weight = (float)($row['weight'] ?? 1.0);
if ($weight <= 0) {
$weight = 1.0;
}
$out[] = [
'questionnaireID' => $qnID,
'weight' => $weight,
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
];
$idx++;
}
return $out;
}
function qdb_sync_profile_questionnaires(PDO $pdo, string $profileID, array $members): void {
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
->execute([':pid' => $profileID]);
$ins = $pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, :w, :o)'
);
foreach ($members as $m) {
$ins->execute([
':pid' => $profileID,
':qn' => $m['questionnaireID'],
':w' => $m['weight'],
':o' => $m['orderIndex'],
]);
}
}
function qdb_recompute_all_profiles_after_change(PDO $pdo, ?string $profileID = null): void {
$clients = $pdo->query('SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = \'completed\'')
->fetchAll(PDO::FETCH_COLUMN);
foreach ($clients as $clientCode) {
qdb_recompute_profile_scores_for_client($pdo, (string)$clientCode);
}
}

44
handlers/session.php Normal file
View File

@ -0,0 +1,44 @@
<?php
/**
* GET /api/session — verify Bearer token is still active and return role metadata.
*/
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$token = get_bearer_token();
if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
}
$rec = token_get_record($token);
if (!$rec) {
json_error('UNAUTHORIZED', 'Invalid or expired token', 401);
}
$role = (string)($rec['role'] ?? '');
$username = '';
if (($rec['userID'] ?? '') !== '') {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
$stmt->execute([':uid' => $rec['userID']]);
$row = $stmt->fetchColumn();
$username = $row !== false ? (string)$row : '';
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Session check', null, $tmpDb ?? null, $lockFp ?? null);
}
}
json_success([
'valid' => true,
'user' => $username,
'role' => $role,
'userID' => $rec['userID'] ?? '',
'mustChangePassword' => !empty($rec['temp']),
]);

70
handlers/settings.php Normal file
View File

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../lib/settings.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$settings = qdb_settings_get_on_pdo($pdo);
$sessionCount = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn();
qdb_discard($tmpDb, $lockFp);
json_success([
'settings' => $settings,
'activeSessions' => $sessionCount,
'defaults' => qdb_settings_defaults(),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load settings', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
case 'PATCH':
$body = read_json_body();
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$current = qdb_settings_get_on_pdo($pdo);
$merged = qdb_settings_validate_and_merge($body, $current);
qdb_settings_save_on_pdo($pdo, $merged);
qdb_save($tmpDb, $lockFp);
json_success(['settings' => $merged]);
} catch (InvalidArgumentException $e) {
json_error('INVALID_FIELD', $e->getMessage(), 400);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save settings', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$action = trim((string)($body['action'] ?? ''));
if ($action !== 'revokeAllSessions') {
json_error('BAD_REQUEST', 'Unknown action', 400);
}
$confirm = trim((string)($body['confirmPhrase'] ?? ''));
$revokeAllPhrase = 'REVOKE ALL SESSIONS';
if ($confirm !== $revokeAllPhrase) {
json_error(
'CONFIRMATION_REQUIRED',
'Type exactly "' . $revokeAllPhrase . '" to revoke every session',
400
);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$removed = token_revoke_all_on_pdo($pdo);
qdb_save($tmpDb, $lockFp);
json_success(['revokedSessions' => $removed]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Revoke all sessions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

302
handlers/translations.php Normal file
View File

@ -0,0 +1,302 @@
<?php
$tokenRec = require_valid_token_web();
$validTypes = ['question', 'answer_option', 'string', 'app_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 = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(['languages' => $rows]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
}
}
if (!empty($_GET['all'])) {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
}
$qnRows = $pdo->query("
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
")->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);
$questionnaires = [];
$allEntries = $appEntries;
foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) {
continue;
}
$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;
}
}
qdb_discard($tmpDb, $lockFp);
json_success([
'languages' => $languages,
'appStrings' => $appEntries,
'questionnaires' => $questionnaires,
'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
}
}
$qnID = trim((string)($_GET['questionnaireID'] ?? ''));
if ($qnID !== '') {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
}
$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,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
}
}
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
if (!in_array($type, $validTypes, true) || (!$id && $type !== 'string')) {
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') {
$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");
}
}
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;
}
json_error('BAD_REQUEST', 'Unknown action', 400);
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$type = $body['type'] ?? '';
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
$name = trim($body['name'] ?? '');
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
if ($lang === QDB_SOURCE_LANGUAGE) {
qdb_ensure_source_language($pdo);
} else {
$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;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
$text = $body['text'] ?? '';
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
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);
}
qdb_put_translation($pdo, $type, $id, $lang, $text);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save translation', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$type = $body['type'] ?? '';
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
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();
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);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete language', null, $tmpDb, $lockFp);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
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')
->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);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete translation', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

427
handlers/users.php Normal file
View File

@ -0,0 +1,427 @@
<?php
$tokenRec = require_valid_token_web();
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
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 {
$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);
json_success([
'users' => $users,
'supervisors' => $supervisors,
'callerRole' => $callerRole,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$username = trim($body['username'] ?? '');
$password = (string)($body['password'] ?? '');
$role = strtolower(trim($body['role'] ?? ''));
$location = trim($body['location'] ?? '');
$supervisorID = trim($body['supervisorID'] ?? '');
$mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1;
if ($username === '' || $password === '' || $role === '') {
json_error('MISSING_FIELDS', 'username, password, and role are required', 400);
}
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
json_error('INVALID_ROLE', 'role must be admin, supervisor, or coach', 400);
}
if (strlen($password) < 6) {
json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400);
}
if ($callerRole === 'supervisor' && $role !== 'coach') {
json_error('FORBIDDEN', 'Supervisors may only create coaches', 403);
}
if ($callerRole === 'supervisor') {
$supervisorID = $callerEntityID;
}
if ($role === 'coach' && $supervisorID === '') {
json_error('MISSING_FIELDS', 'supervisorID is required for coaches', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('DUPLICATE', 'Username already exists', 409);
}
if ($role === 'coach') {
$svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
$svCheck->execute([':sid' => $supervisorID]);
if (!$svCheck->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Supervisor not found', 400);
}
if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Cannot create coaches for another supervisor', 403);
}
}
$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);
$svUsername = null;
if ($role === 'coach') {
[$pdo2, $tmp2, $lk2] = qdb_open_read_or_fail();
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
$svr->execute([':sid' => $supervisorID]);
$svUsername = $svr->fetchColumn() ?: null;
qdb_discard($tmp2, $lk2);
}
json_success([
'userID' => $userID,
'entityID' => $entityID,
'username' => $username,
'role' => $role,
'location' => $location,
'supervisorID' => $role === 'coach' ? $supervisorID : null,
'supervisorUsername' => $svUsername,
'mustChangePassword' => $mustChange,
'createdAt' => $now,
]);
} 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);
}
break;
case 'DELETE':
$body = read_json_body();
$targetUserID = trim($body['userID'] ?? '');
if ($targetUserID === '') {
json_error('MISSING_FIELDS', 'userID is required', 400);
}
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
json_error('FORBIDDEN', 'Cannot delete your own account', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$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);
json_error('NOT_FOUND', 'User not found', 404);
}
if ($callerRole === 'supervisor') {
if ($target['role'] !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Supervisors can only delete 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);
}
}
$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);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

401
lib/analytics.php Normal file
View File

@ -0,0 +1,401 @@
<?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;
}
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)
ON CONFLICT(clientCode) DO UPDATE SET
note = excluded.note,
updatedByUserID = excluded.updatedByUserID,
updatedAt = excluded.updatedAt'
)->execute([
':cc' => $clientCode,
':n' => $note,
':uid' => $tokenRec['userID'] ?? '',
':ts' => time(),
]);
}
function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
if ($role === 'supervisor') {
$coachWhere = 'co.supervisorID = :eid';
$coachParams = [':eid' => $entityID];
} else {
$coachWhere = '1=1';
$coachParams = [];
}
$now = time();
$d7 = $now - 7 * 86400;
$d30 = $now - 30 * 86400;
$sql = "
SELECT co.coachID, co.username,
sv.username AS supervisorUsername,
(SELECT COUNT(*) FROM client c
WHERE c.coachID = co.coachID AND COALESCE(c.archived, 0) = 0) AS clientCount,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS totalSubmissions,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d7) AS submissions7d,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d30) AS submissions30d,
(SELECT MAX(qs.submittedAt) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS lastSubmissionAt
FROM coach co
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $coachWhere
ORDER BY co.username
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':d7' => $d7, ':d30' => $d30], $coachParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $r) {
$last = $r['lastSubmissionAt'] !== null ? (int)$r['lastSubmissionAt'] : null;
$out[] = [
'coachID' => $r['coachID'],
'username' => $r['username'],
'supervisorUsername'=> $r['supervisorUsername'] ?? '',
'clientCount' => (int)$r['clientCount'],
'totalSubmissions' => (int)$r['totalSubmissions'],
'submissionsLast7d' => (int)$r['submissions7d'],
'submissionsLast30d'=> (int)$r['submissions30d'],
'lastSubmissionAt' => $last ? date('Y-m-d H:i', $last) : '',
];
}
return $out;
}
function qdb_coach_recent_submissions(
PDO $pdo,
array $tokenRec,
string $coachID,
int $limit = 100,
int $offset = 0
): array {
$coachID = trim($coachID);
if ($coachID === '') {
json_error('INVALID_FIELD', 'coachID is required', 400);
}
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
$chk = $pdo->prepare('SELECT coachID, supervisorID FROM coach WHERE coachID = :id');
$chk->execute([':id' => $coachID]);
$coach = $chk->fetch(PDO::FETCH_ASSOC);
if (!$coach) {
json_error('NOT_FOUND', 'Counselor not found', 404);
}
if ($role === 'supervisor' && ($coach['supervisorID'] ?? '') !== $entityID) {
json_error('FORBIDDEN', 'Not authorized for this coach', 403);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$limit = max(1, min($limit, 200));
$offset = max(0, $offset);
$countSql = "
SELECT COUNT(*)
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE cl.coachID = :cid AND ($rbacClause)
";
$countStmt = $pdo->prepare($countSql);
$countStmt->execute(array_merge([':cid' => $coachID], $rbacParams));
$total = (int)$countStmt->fetchColumn();
$sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole,
qs.clientCode, qs.questionnaireID, qn.name AS questionnaireName
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID
WHERE cl.coachID = :cid AND ($rbacClause)
ORDER BY qs.submittedAt DESC
LIMIT $limit OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':cid' => $coachID], $rbacParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$submissions = array_map(static function ($r) {
return [
'submissionID' => $r['submissionID'],
'version' => (int)$r['version'],
'submittedAt' => $r['submittedAt'] ? date('Y-m-d H:i', (int)$r['submittedAt']) : '',
'submittedByRole' => $r['submittedByRole'] ?? '',
'clientCode' => $r['clientCode'],
'questionnaireID' => $r['questionnaireID'],
'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'],
];
}, $rows);
return [
'submissions' => $submissions,
'total' => $total,
'limit' => $limit,
'offset' => $offset,
];
}

998
lib/api_log.php Normal file
View File

@ -0,0 +1,998 @@
<?php
/**
* Activity API logs (JSON Lines). One file per calendar day; rotates when a file
* exceeds QDB_API_LOG_MAX_BYTES (default 10 MiB) to api-YYYY-MM-DD-002.log, etc.
*
* Recorded activity (not routine website page loads):
* - app_sync: GET /app_questionnaires (mobile sync)
* - app_change: POST/PUT/PATCH/DELETE from the Android app
* - web_change: POST/PUT/PATCH/DELETE from the management website (incl. translation labels)
* - web_export: Website downloads (CSV/ZIP/JSON exports, translation bundle)
*
* Set QDB_API_LOG=0 to disable. Set QDB_API_LOG_DIR for a custom path.
* Default directory is uploads/logs/api (writable by PHP alongside the database).
*/
/** @var list<string> */
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export', 'api_error'];
function qdb_api_log_enabled(): bool
{
$v = qdb_env_get('QDB_API_LOG');
if ($v === null || $v === '') {
return true;
}
$v = strtolower(trim($v));
return !in_array($v, ['0', 'false', 'no', 'off'], true);
}
function qdb_api_log_dir(): string
{
static $resolved = null;
if ($resolved !== null) {
return $resolved;
}
$custom = qdb_env_get('QDB_API_LOG_DIR');
if ($custom !== null && $custom !== '') {
$resolved = rtrim($custom, '/\\');
return $resolved;
}
$root = dirname(__DIR__);
foreach ([$root . '/uploads/logs/api', $root . '/logs/api'] as $candidate) {
if (qdb_api_log_dir_is_usable($candidate)) {
$resolved = $candidate;
return $resolved;
}
}
$resolved = $root . '/uploads/logs/api';
return $resolved;
}
function qdb_api_log_dir_is_usable(string $dir): bool
{
if (is_dir($dir)) {
return is_writable($dir);
}
$parent = dirname($dir);
if (is_dir($parent) && is_writable($parent)) {
return true;
}
if (@mkdir($dir, 0775, true)) {
return is_writable($dir);
}
return is_dir($dir) && is_writable($dir);
}
/**
* Diagnostics for admin UI (permissions, path, enabled flag).
*
* @return array<string, mixed>
*/
function qdb_api_log_status(): array
{
$dir = qdb_api_log_dir();
$enabled = qdb_api_log_enabled();
$exists = is_dir($dir);
$writable = false;
$probeError = null;
if ($enabled) {
if (!$exists) {
if (!@mkdir($dir, 0775, true)) {
$probeError = 'Cannot create directory: ' . $dir;
}
$exists = is_dir($dir);
}
if ($exists && !is_writable($dir)) {
$probeError = 'Directory exists but is not writable by PHP: ' . $dir;
} elseif ($exists) {
$probe = $dir . '/.write_probe';
if (@file_put_contents($probe, (string)time() . "\n", LOCK_EX) === false) {
$probeError = 'Cannot write files in: ' . $dir;
} else {
$writable = true;
@unlink($probe);
}
}
}
$phpUser = null;
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
$info = posix_getpwuid(posix_geteuid());
if (is_array($info) && !empty($info['name'])) {
$phpUser = (string)$info['name'];
}
}
return [
'enabled' => $enabled,
'dir' => $dir,
'exists' => $exists,
'writable' => $writable,
'error' => $probeError,
'php_user' => $phpUser,
'fix_hint' => $writable || !$enabled
? null
: 'On the server run: mkdir -p ' . $dir . ' && chown -R www-data:www-data '
. dirname($dir) . ' && chmod -R 775 ' . dirname($dir)
. ' (adjust www-data to your PHP user). Or set QDB_API_LOG_DIR in .env to a writable path.',
];
}
function qdb_api_log_max_bytes(): int
{
$v = qdb_env_get('QDB_API_LOG_MAX_BYTES');
if ($v !== null && $v !== '' && ctype_digit($v)) {
return max(1024 * 1024, (int)$v);
}
return 10 * 1024 * 1024;
}
function qdb_api_log_body_max_chars(): int
{
$v = qdb_env_get('QDB_API_LOG_BODY_MAX');
if ($v !== null && $v !== '' && ctype_digit($v)) {
return max(256, (int)$v);
}
return 4096;
}
/**
* Whether this request should be written to the activity log.
*/
function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string
{
$method = strtoupper($method);
$client = strtolower(trim($client));
$route = strtolower(trim($route));
if ($method === 'OPTIONS' || in_array($route, ['activity-log', 'session'], true)) {
return null;
}
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return $client === 'web' ? 'web_change' : 'app_change';
}
if ($method === 'GET') {
if ($route === 'app_questionnaires' && $client !== 'web') {
return 'app_sync';
}
if (qdb_api_log_is_website_download($route)) {
return 'web_export';
}
}
return null;
}
/** Website GET that downloads or exports data (not routine list/load). */
function qdb_api_log_is_website_download(string $route): bool
{
if (in_array($route, ['export', 'backup'], true)) {
return true;
}
if ($route === 'translations' && !empty($_GET['exportBundle'])) {
return true;
}
return false;
}
/** @param array<string, mixed> $context */
function qdb_api_log_begin(string $method, string $route, array $context = []): void
{
if (!qdb_api_log_enabled()) {
return;
}
$client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
$activity = qdb_api_log_classify_activity($method, $client, $route);
$GLOBALS['qdb_api_log_ctx'] = array_merge([
'started_at' => microtime(true),
'method' => strtoupper($method),
'route' => $route,
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
'ip' => qdb_api_log_client_ip(),
'client' => $client,
'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')),
], $context);
if ($activity !== null) {
$GLOBALS['qdb_api_log_ctx']['activity'] = $activity;
} else {
$GLOBALS['qdb_api_log_ctx']['skip_success'] = true;
}
}
function qdb_api_log_note_exception(Throwable $e): void
{
if (empty($GLOBALS['qdb_api_log_ctx'])) {
return;
}
$GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage();
$GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class;
}
function qdb_api_log_note_api_error(string $code, string $message, int $status): void
{
if (empty($GLOBALS['qdb_api_log_ctx'])) {
return;
}
$GLOBALS['qdb_api_log_ctx']['api_error_code'] = $code;
$GLOBALS['qdb_api_log_ctx']['api_error_message'] = $message;
$GLOBALS['qdb_api_log_ctx']['api_error_status'] = $status;
}
function qdb_api_log_finish(): void
{
$ctx = $GLOBALS['qdb_api_log_ctx'] ?? null;
unset($GLOBALS['qdb_api_log_ctx']);
if (!is_array($ctx) || !qdb_api_log_enabled()) {
return;
}
$started = (float)($ctx['started_at'] ?? microtime(true));
$status = http_response_code();
if ($status === false || $status === 0) {
$status = 200;
}
$isError = $status >= 400
|| !empty($ctx['exception'])
|| !empty($ctx['api_error_code']);
if (!empty($ctx['skip_success']) && !$isError) {
return;
}
$token = get_bearer_token();
$auth = ['role' => null, 'userID' => null, 'token_hint' => null];
if ($token !== null && $token !== '') {
$auth['token_hint'] = qdb_api_log_token_hint($token);
try {
$rec = token_get_record($token, false);
if (is_array($rec)) {
$auth['role'] = $rec['role'] ?? null;
$auth['userID'] = $rec['userID'] ?? null;
if (!empty($rec['temp'])) {
$auth['temp_token'] = true;
}
}
} catch (Throwable) {
$auth['token_lookup'] = 'failed';
}
}
$rawBody = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : '';
$bodyLog = qdb_api_log_format_body($rawBody);
$queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? ''));
$actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null);
$targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog);
$activity = $ctx['activity'] ?? null;
if ($isError) {
$activity = 'api_error';
}
$entry = [
'ts' => gmdate('c'),
'activity' => $activity,
'method' => $ctx['method'] ?? '',
'route' => $ctx['route'] ?? '',
'status' => $status,
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
'ip' => $ctx['ip'] ?? '',
'client' => $ctx['client'] ?? '',
'uri' => $ctx['uri'] ?? '',
'query' => $queryLog,
'role' => $auth['role'],
'userID' => $auth['userID'],
'actor_username' => $actorUsername,
'target_username' => $targetUsername,
'token_hint' => $auth['token_hint'],
'temp_token' => $auth['temp_token'] ?? null,
'changes' => qdb_api_log_build_changes(
(string)($ctx['method'] ?? ''),
(string)($ctx['route'] ?? ''),
$bodyLog,
$queryLog
),
];
if (!empty($ctx['api_error_code'])) {
$entry['error_code'] = (string)$ctx['api_error_code'];
$entry['error_message'] = (string)($ctx['api_error_message'] ?? '');
$entry['error'] = $entry['error_code'] . ': ' . $entry['error_message'];
}
if (!empty($ctx['exception'])) {
$entry['error_internal'] = (string)$ctx['exception'];
if (!empty($ctx['exception_class'])) {
$entry['error_class'] = (string)$ctx['exception_class'];
}
if (empty($entry['error'])) {
$entry['error'] = 'SERVER_ERROR: ' . $entry['error_internal'];
}
}
if (($ctx['user_agent'] ?? '') !== '') {
$entry['user_agent'] = $ctx['user_agent'];
}
try {
$line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($line === false) {
return;
}
qdb_api_log_append_line($line . "\n");
} catch (Throwable $e) {
error_log('api_log write failed: ' . $e->getMessage());
}
}
function qdb_api_log_client_ip(): string
{
foreach ([
'HTTP_CF_CONNECTING_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
] as $key) {
$v = trim((string)($_SERVER[$key] ?? ''));
if ($v === '') {
continue;
}
if ($key === 'HTTP_X_FORWARDED_FOR') {
$v = trim(explode(',', $v)[0]);
}
if (filter_var($v, FILTER_VALIDATE_IP)) {
return $v;
}
}
return '';
}
function qdb_api_log_token_hint(string $token): string
{
$len = strlen($token);
if ($len <= 8) {
return '[token]';
}
return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)";
}
function qdb_api_log_redact_query_string(string $qs): ?string
{
if ($qs === '') {
return null;
}
parse_str($qs, $params);
if (!is_array($params) || $params === []) {
return $qs;
}
foreach ($params as $k => $_) {
if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) {
$params[$k] = '[REDACTED]';
}
}
return http_build_query($params);
}
/**
* @return array<string, mixed>|string|null
*/
function qdb_api_log_format_body(string $raw): array|string|null
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$max = qdb_api_log_body_max_chars();
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$redacted = qdb_api_log_redact_value($decoded);
$json = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
return '[unencodable body]';
}
if (strlen($json) > $max) {
return substr($json, 0, $max) . '…[truncated]';
}
return $redacted;
}
if (strlen($raw) > $max) {
return substr($raw, 0, $max) . '…[truncated]';
}
return $raw;
}
/** @param mixed $value @return mixed */
function qdb_api_log_redact_value(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}
$isList = array_keys($value) === range(0, count($value) - 1);
$out = [];
foreach ($value as $k => $v) {
$lk = strtolower((string)$k);
if (in_array($lk, [
'password', 'old_password', 'new_password', 'passwordhash',
'token', 'authorization', 'secret',
], true)) {
$out[$k] = '[REDACTED]';
} elseif ($lk === 'encrypted' && is_string($v)) {
$out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]';
} elseif (is_array($v)) {
$out[$k] = qdb_api_log_redact_value($v);
} else {
$out[$k] = $v;
}
}
return $isList ? array_values($out) : $out;
}
/**
* Field-level change list for the activity log UI (passwords stay redacted).
*
* @param mixed $body Redacted decoded body or null
* @return list<array{field: string, value: string|int|float|bool|null}>
*/
function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array
{
$changes = [];
$max = 100;
if ($queryString !== null && $queryString !== '') {
parse_str($queryString, $params);
if (is_array($params)) {
foreach ($params as $k => $v) {
if (count($changes) >= $max) {
break;
}
if (!is_scalar($v)) {
continue;
}
$changes[] = [
'field' => 'query.' . (string)$k,
'value' => qdb_api_log_change_scalar($v),
];
}
}
}
if (is_array($body)) {
qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max);
} elseif (is_string($body) && $body !== '') {
$changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)];
}
if ($changes === [] && strtoupper($method) === 'DELETE') {
$changes[] = ['field' => '_action', 'value' => 'delete'];
}
return $changes;
}
/**
* @param list<array{field: string, value: string|int|float|bool|null}> $out
*/
function qdb_api_log_flatten_for_changes(
array $data,
string $prefix,
array &$out,
int $depth,
int $maxDepth,
int $maxFields
): void {
if ($depth >= $maxDepth || count($out) >= $maxFields) {
return;
}
foreach ($data as $k => $v) {
if (count($out) >= $maxFields) {
return;
}
$key = (string)$k;
$field = $prefix === '' ? $key : $prefix . '.' . $key;
if (is_array($v)) {
$childList = array_keys($v) === range(0, count($v) - 1);
$n = count($v);
if ($n === 0) {
$out[] = ['field' => $field, 'value' => '[]'];
continue;
}
if ($childList && $n > 8) {
$out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]'];
continue;
}
if (!$childList && $depth + 1 >= $maxDepth) {
$out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]'];
continue;
}
qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields);
} else {
$out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)];
}
}
}
/** @return string|int|float|bool|null */
function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null
{
if ($v === null) {
return null;
}
if (is_bool($v) || is_int($v) || is_float($v)) {
return $v;
}
$s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE);
if ($s === false) {
return '';
}
if (strlen($s) > 800) {
return substr($s, 0, 800) . '…';
}
return $s;
}
function qdb_api_log_append_line(string $line): void
{
$dir = qdb_api_log_dir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
$err = error_get_last();
throw new RuntimeException(
'Cannot create log directory: ' . $dir
. ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
);
}
if (!is_writable($dir)) {
throw new RuntimeException('Log directory not writable: ' . $dir);
}
$path = qdb_api_log_resolve_write_path($dir);
if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) {
$err = error_get_last();
throw new RuntimeException(
'Cannot write API log: ' . $path
. ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
);
}
}
function qdb_api_log_resolve_write_path(string $dir): string
{
$date = date('Y-m-d');
$base = $dir . '/api-' . $date;
$max = qdb_api_log_max_bytes();
$primary = $base . '.log';
if (!is_file($primary) || filesize($primary) < $max) {
return $primary;
}
for ($part = 2; $part <= 999; $part++) {
$path = sprintf('%s-%03d.log', $base, $part);
if (!is_file($path) || filesize($path) < $max) {
return $path;
}
}
return sprintf('%s-%03d.log', $base, 999);
}
/**
* Read activity log entries for admin UI (newest first).
*
* @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool}
*/
function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200, bool $errorsOnly = false): array
{
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$limit = max(1, min(1000, $limit));
$activityFilter = strtolower(trim($activityFilter));
if ($activityFilter === 'errors') {
$errorsOnly = true;
$activityFilter = '';
}
if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) {
$activityFilter = '';
}
$dir = qdb_api_log_dir();
$files = qdb_api_log_files_for_date($dir, $date);
$entries = [];
foreach ($files as $file) {
$lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
continue;
}
foreach ($lines as $line) {
$row = json_decode($line, true);
if (!is_array($row)) {
continue;
}
if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) {
continue;
}
if ($errorsOnly) {
$rowActivity = $row['activity'] ?? '';
$rowStatus = (int)($row['status'] ?? 0);
if ($rowActivity !== 'api_error' && $rowStatus < 400 && empty($row['error'])) {
continue;
}
}
$entries[] = qdb_api_log_entry_for_ui($row);
}
}
usort($entries, static function (array $a, array $b): int {
return strcmp($b['ts'] ?? '', $a['ts'] ?? '');
});
$truncated = count($entries) > $limit;
if ($truncated) {
$entries = array_slice($entries, 0, $limit);
}
$entries = qdb_api_log_enrich_entries_usernames($entries);
return [
'date' => $date,
'activity' => $activityFilter,
'errorsOnly' => $errorsOnly,
'entries' => $entries,
'files' => array_map('basename', $files),
'truncated' => $truncated,
'kinds' => QDB_API_LOG_ACTIVITIES,
'logStatus' => qdb_api_log_status(),
];
}
/** @return list<string> */
function qdb_api_log_files_for_date(string $dir, string $date): array
{
if (!is_dir($dir)) {
return [];
}
$pattern = $dir . '/api-' . $date . '*.log';
$files = glob($pattern) ?: [];
sort($files);
return $files;
}
/** @param array<string, mixed> $row @return array<string, mixed> */
function qdb_api_log_entry_for_ui(array $row): array
{
$changes = $row['changes'] ?? null;
if (!is_array($changes) || $changes === []) {
$changes = qdb_api_log_build_changes(
(string)($row['method'] ?? ''),
(string)($row['route'] ?? ''),
$row['body'] ?? null,
isset($row['query']) ? (string)$row['query'] : null
);
}
$changes = qdb_api_log_sort_changes_for_display($changes);
$actorUsername = $row['actor_username'] ?? null;
$targetUsername = $row['target_username'] ?? null;
return [
'ts' => $row['ts'] ?? '',
'activity' => $row['activity'] ?? '',
'method' => $row['method'] ?? '',
'route' => $row['route'] ?? '',
'status' => $row['status'] ?? null,
'duration_ms' => $row['duration_ms'] ?? null,
'role' => $row['role'] ?? null,
'userID' => $row['userID'] ?? null,
'actor_username' => is_string($actorUsername) ? $actorUsername : null,
'target_username' => is_string($targetUsername) ? $targetUsername : null,
'client' => $row['client'] ?? '',
'ip' => $row['ip'] ?? '',
'query' => $row['query'] ?? null,
'changes' => $changes,
'summary' => qdb_api_log_summarize_changes($changes, $targetUsername),
'error' => $row['error'] ?? null,
'error_code' => $row['error_code'] ?? null,
'error_message' => $row['error_message'] ?? null,
];
}
function qdb_api_log_lookup_username(?string $userID): ?string
{
if ($userID === null || $userID === '') {
return null;
}
try {
require_once __DIR__ . '/../db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
$stmt->execute([':uid' => $userID]);
$name = $stmt->fetchColumn();
qdb_discard($tmpDb, $lockFp);
return $name !== false ? (string)$name : null;
} catch (Throwable) {
return null;
}
}
function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string
{
if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) {
return trim((string)$body['username']);
}
if ($queryString !== null && $queryString !== '') {
parse_str($queryString, $params);
if (!empty($params['username']) && is_scalar($params['username'])) {
return trim((string)$params['username']);
}
}
return null;
}
/**
* Resolve userID / supervisorID in log rows to usernames for retraceability.
*
* @param list<array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
function qdb_api_log_enrich_entries_usernames(array $entries): array
{
if ($entries === []) {
return $entries;
}
$userIds = [];
$supervisorIds = [];
foreach ($entries as $entry) {
if (!empty($entry['userID']) && is_string($entry['userID'])) {
$userIds[$entry['userID']] = true;
}
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
$userIds[$entry['userID']] = true;
}
foreach ($entry['changes'] ?? [] as $change) {
if (!is_array($change)) {
continue;
}
$field = strtolower((string)($change['field'] ?? ''));
$value = $change['value'] ?? null;
if (!is_string($value) || $value === '' || str_starts_with($value, '[')) {
continue;
}
if ($field === 'userid' || str_ends_with($field, '.userid')) {
$userIds[$value] = true;
}
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
$supervisorIds[$value] = true;
}
}
}
$userMap = qdb_api_log_load_user_map(array_keys($userIds));
$svMap = qdb_api_log_load_supervisor_map(array_keys($supervisorIds));
foreach ($entries as $i => $entry) {
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
$entry['actor_username'] = $userMap[$entry['userID']] ?? null;
}
if (empty($entry['target_username'])) {
$entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []);
}
$enriched = [];
foreach ($entry['changes'] ?? [] as $change) {
if (!is_array($change)) {
continue;
}
$change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap);
$enriched[] = $change;
}
$entry['changes'] = qdb_api_log_sort_changes_for_display($enriched);
$entry['summary'] = qdb_api_log_summarize_changes(
$entry['changes'],
$entry['target_username'] ?? null
);
$entries[$i] = $entry;
}
return $entries;
}
/** @param list<string> $userIds @return array<string, string> */
function qdb_api_log_load_user_map(array $userIds): array
{
$userIds = array_values(array_filter($userIds, static fn($id) => $id !== ''));
if ($userIds === []) {
return [];
}
try {
require_once __DIR__ . '/../db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$stmt = $pdo->prepare(
"SELECT userID, username FROM users WHERE userID IN ($placeholders)"
);
$stmt->execute($userIds);
$map = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$map[(string)$row['userID']] = (string)$row['username'];
}
qdb_discard($tmpDb, $lockFp);
return $map;
} catch (Throwable) {
return [];
}
}
/** @param list<string> $supervisorIds @return array<string, string> */
function qdb_api_log_load_supervisor_map(array $supervisorIds): array
{
$supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== ''));
if ($supervisorIds === []) {
return [];
}
try {
require_once __DIR__ . '/../db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$placeholders = implode(',', array_fill(0, count($supervisorIds), '?'));
$stmt = $pdo->prepare(
"SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)"
);
$stmt->execute($supervisorIds);
$map = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$map[(string)$row['supervisorID']] = (string)$row['username'];
}
qdb_discard($tmpDb, $lockFp);
return $map;
} catch (Throwable) {
return [];
}
}
/** @param list<array{field?: string, value?: mixed}> $changes */
function qdb_api_log_target_from_changes(array $changes): ?string
{
foreach ($changes as $c) {
$field = strtolower((string)($c['field'] ?? ''));
if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) {
return trim((string)$c['value']);
}
}
return null;
}
/**
* @param array<string, string> $userMap
* @param array<string, string> $svMap
*/
function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string
{
$field = strtolower((string)($change['field'] ?? ''));
$value = $change['value'] ?? null;
if ($value === null) {
return 'null';
}
if (!is_scalar($value)) {
return '';
}
$str = (string)$value;
if ($field === 'username') {
return $str;
}
if ($field === 'userid' || str_ends_with($field, '.userid')) {
return $userMap[$str] ?? $str;
}
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
return $svMap[$str] ?? $str;
}
if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) {
return $str;
}
if ($field === 'mustchangepassword') {
return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)';
}
if ($field === 'password' || str_contains($field, 'password')) {
return (string)$value;
}
return $str;
}
/**
* @param list<array{field?: string, value?: mixed}> $changes
* @return list<array{field?: string, value?: mixed}>
*/
function qdb_api_log_sort_changes_for_display(array $changes): array
{
$priority = [
'username' => 0,
'type' => 1,
'text' => 2,
'languagecode' => 3,
'id' => 4,
'role' => 5,
'clientcode' => 6,
'action' => 7,
'query.id' => 10,
'query.clients' => 11,
'query.clientcode' => 12,
'query.answers' => 13,
'query.translations' => 14,
'query.bundle' => 15,
'query.exportall' => 16,
'query.exportbundle' => 17,
'supervisorid' => 18,
'userid' => 19,
'mustchangepassword' => 20,
'password' => 21,
'location' => 22,
];
usort($changes, static function (array $a, array $b) use ($priority): int {
$fa = strtolower((string)($a['field'] ?? ''));
$fb = strtolower((string)($b['field'] ?? ''));
$pa = $priority[$fa] ?? 50;
$pb = $priority[$fb] ?? 50;
if ($pa !== $pb) {
return $pa <=> $pb;
}
return strcmp($fa, $fb);
});
return $changes;
}
/** @param list<array{field: string, value: mixed, display?: string}> $changes */
function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string
{
if ($targetUsername !== null && $targetUsername !== '') {
return 'user=' . $targetUsername;
}
if ($changes === []) {
return '';
}
$parts = [];
foreach (array_slice($changes, 0, 8) as $c) {
$field = (string)($c['field'] ?? '');
if ($field === '') {
continue;
}
$label = (string)($c['display'] ?? '');
$value = $c['value'] ?? '';
if ($label !== '' && $label !== (string)$value) {
$parts[] = $field . '=' . $label;
} elseif ($value === null) {
$parts[] = $field . '=null';
} elseif (is_bool($value)) {
$parts[] = $field . '=' . ($value ? 'true' : 'false');
} else {
$parts[] = $field . '=' . (string)$value;
}
}
$summary = implode(', ', $parts);
if (count($changes) > 8) {
$summary .= ', …';
}
if (strlen($summary) > 240) {
$summary = substr($summary, 0, 240) . '…';
}
return $summary;
}

306
lib/app_answers.php Normal file
View File

@ -0,0 +1,306 @@
<?php
/**
* Mobile app answer ingest/export helpers (submit payload ↔ client_answer rows).
*/
/** Encode multi-select keys the same way the Android app stores them locally. */
function qdb_encode_multi_check_values(array $keys): string {
$clean = [];
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k !== '') {
$clean[$k] = true;
}
}
return json_encode(array_keys($clean), JSON_UNESCAPED_UNICODE);
}
/** @return list<string> */
function qdb_decode_multi_check_values(?string $raw): array {
if ($raw === null || trim($raw) === '') {
return [];
}
$trimmed = trim($raw);
if ($trimmed[0] === '[') {
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$out = [];
foreach ($decoded as $v) {
$s = trim((string)$v);
if ($s !== '') {
$out[] = $s;
}
}
return $out;
}
}
$sep = str_contains($trimmed, ',') ? ',' : (str_contains($trimmed, ';') ? ';' : null);
if ($sep !== null) {
$parts = [];
foreach (explode($sep, $trimmed) as $p) {
$s = trim($p, " \t\n\r\0\x0B\"'");
if ($s !== '') {
$parts[] = $s;
}
}
return $parts;
}
return [$trimmed];
}
/**
* Build glass-scale symptom JSON from the current submit only (no merge with prior rows).
*
* @param array<string, string> $symptomLabels symptom key => selected label
*/
function qdb_build_glass_symptom_json(array $symptomLabels): ?string {
$data = [];
foreach ($symptomLabels as $key => $label) {
$k = trim((string)$key);
$v = trim((string)$label);
if ($k !== '' && $v !== '') {
$data[$k] = $v;
}
}
if ($data === []) {
return null;
}
return json_encode($data, JSON_UNESCAPED_UNICODE);
}
/**
* Group raw POST answer rows by question short id; merge multi_check option keys.
*
* @param array<string, string> $shortIdToType question localId => layout type
* @return list<array<string, mixed>>
*/
function qdb_group_app_submit_answers(array $answers, array $shortIdToType, array $symptomParentMap): array {
$grouped = [];
$order = [];
foreach ($answers as $a) {
if (!is_array($a)) {
continue;
}
$shortId = trim($a['questionID'] ?? '');
if ($shortId === '') {
continue;
}
if (isset($symptomParentMap[$shortId])) {
$order[] = $shortId;
$grouped[$shortId] = $a;
continue;
}
$type = $shortIdToType[$shortId] ?? '';
if ($type === 'multi_check_box_question') {
if (!isset($grouped[$shortId])) {
$order[] = $shortId;
$grouped[$shortId] = [
'questionID' => $shortId,
'multiOptionKeys' => [],
'answeredAt' => $a['answeredAt'] ?? null,
];
}
$key = trim((string)($a['answerOptionKey'] ?? ''));
if ($key !== '') {
$grouped[$shortId]['multiOptionKeys'][$key] = true;
}
continue;
}
$order[] = $shortId;
$grouped[$shortId] = $a;
}
$out = [];
foreach ($order as $shortId) {
$row = $grouped[$shortId];
if (!empty($row['multiOptionKeys'])) {
$keys = array_keys($row['multiOptionKeys']);
$out[] = [
'questionID' => $shortId,
'freeTextValue' => qdb_encode_multi_check_values($keys),
'answeredAt' => $row['answeredAt'] ?? null,
];
} else {
unset($row['multiOptionKeys']);
$out[] = $row;
}
}
return $out;
}
/**
* Export one client's answers for one questionnaire into app submit shape.
*
* @return array{answers: list<array<string, mixed>>, sumPoints: int, completedAt: ?int}
*/
function qdb_export_app_questionnaire_answers(
PDO $pdo,
string $clientCode,
string $qnID
): array {
$qStmt = $pdo->prepare(
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn"
);
$qStmt->execute([':qn' => $qnID]);
$shortToFull = [];
$fullToShort = [];
$fullToType = [];
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$full = $row['questionID'];
$parts = explode('__', $full);
$short = end($parts);
$shortToFull[$short] = $full;
$fullToShort[$full] = $short;
$fullToType[$full] = $row['type'] ?? '';
}
$aoStmt = $pdo->prepare("
SELECT ao.answerOptionID, ao.questionID, ao.defaultText
FROM answer_option ao
JOIN question q ON q.questionID = ao.questionID
WHERE q.questionnaireID = :qn
");
$aoStmt->execute([':qn' => $qnID]);
$optionIdToKey = [];
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionIdToKey[$ao['answerOptionID']] = $ao['defaultText'];
}
$caStmt = $pdo->prepare("
SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue, ca.answeredAt
FROM client_answer ca
JOIN question q ON q.questionID = ca.questionID
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn
");
$caStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$rows = $caStmt->fetchAll(PDO::FETCH_ASSOC);
$export = [];
foreach ($rows as $row) {
$fullQid = $row['questionID'];
$shortId = $fullToShort[$fullQid] ?? '';
if ($shortId === '') {
continue;
}
$type = $fullToType[$fullQid] ?? '';
if ($type === 'glass_scale_question' && ($row['freeTextValue'] ?? '') !== '') {
$decoded = json_decode($row['freeTextValue'], true);
if (is_array($decoded)) {
foreach ($decoded as $symptomKey => $label) {
$sk = trim((string)$symptomKey);
$lab = trim((string)$label);
if ($sk !== '' && $lab !== '') {
$export[] = [
'questionID' => $sk,
'answerOptionKey' => $lab,
];
}
}
}
continue;
}
if ($type === 'multi_check_box_question') {
foreach (qdb_decode_multi_check_values($row['freeTextValue'] ?? null) as $key) {
$export[] = [
'questionID' => $shortId,
'answerOptionKey' => $key,
];
}
if ($export === [] && !empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
$export[] = [
'questionID' => $shortId,
'answerOptionKey' => $optionIdToKey[$row['answerOptionID']],
];
}
continue;
}
$entry = ['questionID' => $shortId];
if (!empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
$entry['answerOptionKey'] = $optionIdToKey[$row['answerOptionID']];
}
if (isset($row['freeTextValue']) && $row['freeTextValue'] !== null && $row['freeTextValue'] !== '') {
$entry['freeTextValue'] = $row['freeTextValue'];
}
if (isset($row['numericValue']) && $row['numericValue'] !== null && $row['numericValue'] !== '') {
$entry['numericValue'] = (float)$row['numericValue'];
}
if (!empty($row['answeredAt'])) {
$entry['answeredAt'] = (int)$row['answeredAt'];
}
$export[] = $entry;
}
$cq = $pdo->prepare(
"SELECT sumPoints, completedAt FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn"
);
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
$meta = $cq->fetch(PDO::FETCH_ASSOC) ?: [];
return [
'answers' => $export,
'sumPoints' => isset($meta['sumPoints']) ? (int)$meta['sumPoints'] : 0,
'completedAt' => isset($meta['completedAt']) ? (int)$meta['completedAt'] : null,
];
}
/**
* Export all completed questionnaires for a client (coach RBAC must be applied by caller).
*
* @return list<array<string, mixed>>
*/
function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): array {
$stmt = $pdo->prepare(
"SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc"
);
$stmt->execute([':cc' => $clientCode]);
$items = [];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) {
$block = qdb_export_app_questionnaire_answers($pdo, $clientCode, $qnID);
$items[] = [
'questionnaireID' => $qnID,
'completedAt' => $block['completedAt'],
'sumPoints' => $block['sumPoints'],
'answers' => $block['answers'],
];
}
return $items;
}
/**
* Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC).
*
* @return list<array{clientCode: string, questionnaires: list<array<string, mixed>>}>
*/
function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array {
if (($tokenRec['role'] ?? '') !== 'coach') {
return [];
}
$coachID = trim((string)($tokenRec['entityID'] ?? ''));
if ($coachID === '') {
return [];
}
$stmt = $pdo->prepare(
'SELECT clientCode FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode'
);
$stmt->execute([':cid' => $coachID]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $clientCode) {
$out[] = [
'clientCode' => $clientCode,
'questionnaires' => qdb_export_app_client_answers_bundle($pdo, (string)$clientCode),
];
}
return $out;
}

221
lib/app_submit_validate.php Normal file
View File

@ -0,0 +1,221 @@
<?php
require_once __DIR__ . '/questionnaire_structure.php';
/**
* Validate Android app questionnaire submit payloads before persisting answers.
*
* @param array<string, mixed>|null $manifest Structure manifest for legacy revision submits
* @return list<array{questionID: string, code: string, message: string}>
*/
function qdb_validate_app_submit_payload(
PDO $pdo,
string $qnID,
array $rawAnswers,
array $shortIdMap,
array $shortIdToType,
array $symptomParentMap,
array $optionMap,
?array $manifest = null
): array {
$errors = [];
if ($rawAnswers === []) {
$errors[] = [
'questionID' => '',
'code' => 'ANSWERS_REQUIRED',
'message' => 'At least one answer is required',
];
return $errors;
}
foreach ($rawAnswers as $idx => $a) {
if (!is_array($a)) {
$errors[] = [
'questionID' => '',
'code' => 'INVALID_ANSWER',
'message' => 'Answer at index ' . $idx . ' must be an object',
];
continue;
}
$shortId = trim($a['questionID'] ?? '');
if ($shortId === '') {
$errors[] = [
'questionID' => '',
'code' => 'MISSING_QUESTION_ID',
'message' => 'Each answer must include questionID',
];
continue;
}
if (isset($symptomParentMap[$shortId])) {
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
if ($label === '') {
$errors[] = [
'questionID' => $shortId,
'code' => 'EMPTY_ANSWER',
'message' => 'Symptom selection is required',
];
}
continue;
}
if (!isset($shortIdMap[$shortId])) {
$errors[] = [
'questionID' => $shortId,
'code' => 'UNKNOWN_QUESTION',
'message' => 'Unknown question: ' . $shortId,
];
continue;
}
$fullQID = $shortIdMap[$shortId];
$type = $shortIdToType[$shortId] ?? '';
$optKey = $a['answerOptionKey'] ?? null;
$free = isset($a['freeTextValue']) ? trim((string)$a['freeTextValue']) : '';
$numeric = $a['numericValue'] ?? null;
$hasNumeric = $numeric !== null && $numeric !== '';
if ($type === 'multi_check_box_question') {
if ($optKey !== null && trim((string)$optKey) !== '') {
continue;
}
if ($free !== '') {
continue;
}
$errors[] = [
'questionID' => $shortId,
'code' => 'EMPTY_ANSWER',
'message' => 'Select at least one option',
];
continue;
}
if ($optKey !== null && trim((string)$optKey) !== '') {
$key = (string)$optKey;
if (!isset($optionMap[$fullQID][$key])) {
$errors[] = [
'questionID' => $shortId,
'code' => 'INVALID_OPTION',
'message' => 'Unknown option key: ' . $key,
];
}
continue;
}
if ($free !== '' || $hasNumeric) {
continue;
}
$errors[] = [
'questionID' => $shortId,
'code' => 'EMPTY_ANSWER',
'message' => 'Answer is empty',
];
}
if ($errors !== []) {
return $errors;
}
require_once __DIR__ . '/app_answers.php';
$grouped = qdb_group_app_submit_answers($rawAnswers, $shortIdToType, $symptomParentMap);
$answeredShort = [];
foreach ($grouped as $row) {
$sid = trim($row['questionID'] ?? '');
if ($sid !== '') {
$answeredShort[$sid] = true;
}
}
if ($manifest !== null) {
return array_merge($errors, qdb_validate_required_answers_from_manifest($manifest, $answeredShort));
}
$qStmt = $pdo->prepare(
'SELECT questionID, type, isRequired, configJson FROM question
WHERE questionnaireID = :qn AND ' . qdb_active_questions_clause('question')
);
$qStmt->execute([':qn' => $qnID]);
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
if ((int)($qRow['isRequired'] ?? 0) !== 1) {
continue;
}
$fullId = $qRow['questionID'];
$parts = explode('__', $fullId);
$short = end($parts);
$type = $qRow['type'] ?? '';
if ($type === 'glass_scale_question') {
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$symptoms = $cfg['symptoms'] ?? [];
if (is_array($symptoms) && $symptoms !== []) {
foreach ($symptoms as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
if (empty($answeredShort[$sk])) {
$errors[] = [
'questionID' => $sk,
'code' => 'MISSING_ANSWER',
'message' => 'Required symptom is not answered',
];
}
}
continue;
}
}
if (empty($answeredShort[$short])) {
$errors[] = [
'questionID' => $short,
'code' => 'MISSING_ANSWER',
'message' => 'Required question is not answered',
];
}
}
return $errors;
}
/**
* @return list<array{questionID: string, code: string, message: string}>
*/
function qdb_validate_required_answers_from_manifest(array $manifest, array $answeredShort): array {
$errors = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry) || empty($qEntry['isRequired'])) {
continue;
}
$shortId = (string)($qEntry['shortId'] ?? '');
$type = (string)($qEntry['type'] ?? '');
if ($shortId === '') {
continue;
}
if ($type === 'glass_scale_question') {
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
if (empty($answeredShort[$sk])) {
$errors[] = [
'questionID' => $sk,
'code' => 'MISSING_ANSWER',
'message' => 'Required symptom is not answered',
];
}
}
continue;
}
if (empty($answeredShort[$shortId])) {
$errors[] = [
'questionID' => $shortId,
'code' => 'MISSING_ANSWER',
'message' => 'Required question is not answered',
];
}
}
return $errors;
}

911
lib/dev_fixture.php Normal file
View File

@ -0,0 +1,911 @@
<?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':
$year = 2018 + ($v % 6);
$month = 1 + ($v % 12);
$day = 1 + ($v % 28);
$precision = $config['precision'] ?? 'full';
$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)
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;
}
$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)
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,
':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).
$pdo->exec('PRAGMA foreign_keys = OFF');
$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();
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
try {
$pdo->exec('PRAGMA foreign_keys = ON');
} catch (Throwable $ignored) {
}
throw $e;
}
return [
'deleted' => $deleted,
'kept' => ['admins' => $keptAdmins],
];
}

60
lib/encrypted_payload.php Normal file
View File

@ -0,0 +1,60 @@
<?php
/**
* Application-layer encryption for sensitive mobile API payloads.
* AES-256-GCM (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
* Legacy CBC envelopes are accepted for compatibility with older app builds.
*/
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
$key = hkdf_session_key_from_token($tokenHex);
return [
'encrypted' => true,
'alg' => 'A256GCM',
'payload' => base64_encode(qdb_aes256_gcm_encrypt_bytes($plainJson, $key)),
];
}
function qdb_decrypt_sensitive_envelope(array $envelope, string $tokenHex): string {
if (empty($envelope['encrypted']) || !isset($envelope['payload']) || !is_string($envelope['payload'])) {
throw new InvalidArgumentException('Invalid encrypted envelope');
}
$raw = base64_decode($envelope['payload'], true);
if ($raw === false) {
throw new InvalidArgumentException('Invalid base64 payload');
}
$key = hkdf_session_key_from_token($tokenHex);
$alg = isset($envelope['alg']) && is_string($envelope['alg']) ? $envelope['alg'] : 'A256CBC';
return match ($alg) {
'A256GCM' => qdb_aes256_gcm_decrypt_bytes($raw, $key),
'A256CBC' => aes256_cbc_decrypt_bytes($raw, $key),
default => throw new InvalidArgumentException('Unsupported encrypted envelope algorithm'),
};
}
function qdb_aes256_gcm_encrypt_bytes(string $plain, string $key): string {
$key = str_pad(substr($key, 0, 32), 32, "\0");
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
if ($cipher === false || strlen($tag) !== 16) {
throw new RuntimeException('openssl_encrypt failed');
}
return $iv . $cipher . $tag;
}
function qdb_aes256_gcm_decrypt_bytes(string $data, string $key): string {
$ivLen = 12;
$tagLen = 16;
if (strlen($data) <= $ivLen + $tagLen) {
throw new InvalidArgumentException('cipher too short');
}
$key = str_pad(substr($key, 0, 32), 32, "\0");
$iv = substr($data, 0, $ivLen);
$tag = substr($data, -$tagLen);
$ct = substr($data, $ivLen, -$tagLen);
$plain = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
if ($plain === false) {
throw new InvalidArgumentException('openssl_decrypt failed');
}
return $plain;
}

89
lib/errors.php Normal file
View File

@ -0,0 +1,89 @@
<?php
/**
* Shared API error helpers for handlers and db_init.
*/
function qdb_is_lock_exception(Throwable $e): bool
{
$msg = $e->getMessage();
return str_contains($msg, 'Could not acquire lock')
|| str_contains($msg, 'Could not open lock file');
}
/**
* Map internal exceptions to safe, identifiable API error messages (details go to error_log).
*/
function qdb_public_error_message(Throwable $e, string $contextLabel = 'Operation'): string
{
$msg = $e->getMessage();
if (qdb_is_lock_exception($e)) {
return 'Database is busy because another update is in progress. Please try again in a moment.';
}
if (str_contains($msg, 'decrypt') || str_contains($msg, 'QDB_MASTER_KEY')) {
return 'Database configuration error. Contact the server administrator.';
}
if (str_contains($msg, 'Could not read') || str_contains($msg, 'Could not write')
|| str_contains($msg, 'Could not save')) {
return 'Database storage error. Contact the server administrator.';
}
if ($e instanceof PDOException) {
return $contextLabel . ' failed due to a database error.';
}
return $contextLabel . ' failed. If this persists, contact support.';
}
/**
* @return array{0: string, 1: string, 2: int} code, message, http status
*/
function qdb_api_error_for_exception(Throwable $e, string $contextLabel = 'Operation'): array
{
if (qdb_is_lock_exception($e)) {
return ['LOCKED', qdb_public_error_message($e, $contextLabel), 503];
}
return ['SERVER_ERROR', qdb_public_error_message($e, $contextLabel), 500];
}
/**
* Standard handler catch: rollback, discard DB temp file, log, JSON error (never returns).
*/
function qdb_handler_fail(
Throwable $e,
string $contextLabel,
?PDO $pdo = null,
?string $tmpDb = null,
$lockFp = null
): never {
if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) {
throw $e;
}
if ($pdo !== null && $pdo->inTransaction()) {
try {
$pdo->rollBack();
} catch (Throwable) {
}
}
if ($tmpDb !== null && $lockFp !== null) {
require_once __DIR__ . '/../db_init.php';
qdb_discard($tmpDb, $lockFp);
}
qdb_emit_api_error($e, $contextLabel);
}
/**
* Log exception and exit with unified JSON error (never returns).
*/
function qdb_emit_api_error(Throwable $e, string $contextLabel): never
{
if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) {
throw $e;
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
error_log($contextLabel . ': ' . $e->getMessage());
[$code, $message, $status] = qdb_api_error_for_exception($e, $contextLabel);
json_error($code, $message, $status);
}

221
lib/keycloak_auth.php Normal file
View File

@ -0,0 +1,221 @@
<?php
/**
* Minimal OpenID Connect support for Keycloak-backed website login.
*
* Required .env values:
* - QDB_KEYCLOAK_ISSUER, e.g. https://.../realms/...
* - QDB_KEYCLOAK_CLIENT_ID
* - QDB_KEYCLOAK_CLIENT_SECRET
*
* Optional:
* - QDB_KEYCLOAK_REDIRECT_URI (defaults to current /api/auth/keycloak-callback)
* - QDB_KEYCLOAK_USERNAME_CLAIM (defaults to preferred_username)
* - QDB_KEYCLOAK_DISPLAY_NAME (defaults to University Konstanz)
*/
function qdb_keycloak_config(): array
{
$issuer = rtrim((string)(qdb_env_get('QDB_KEYCLOAK_ISSUER') ?? ''), '/');
$clientId = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_ID') ?? '');
$clientSecret = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET') ?? '');
return [
'issuer' => $issuer,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => (string)(qdb_env_get('QDB_KEYCLOAK_REDIRECT_URI') ?? qdb_keycloak_default_redirect_uri()),
'username_claim' => (string)(qdb_env_get('QDB_KEYCLOAK_USERNAME_CLAIM') ?? 'preferred_username'),
'display_name' => (string)(qdb_env_get('QDB_KEYCLOAK_DISPLAY_NAME') ?? 'University Konstanz'),
];
}
function qdb_keycloak_is_configured(?array $config = null): bool
{
$config ??= qdb_keycloak_config();
return $config['issuer'] !== '' && $config['client_id'] !== '' && $config['client_secret'] !== '';
}
function qdb_keycloak_default_redirect_uri(): string
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
$base = rtrim(str_replace('\\', '/', dirname($script)), '/');
return $scheme . '://' . $host . $base . '/auth/keycloak-callback';
}
function qdb_keycloak_frontend_url(): string
{
$configured = (string)(qdb_env_get('QDB_KEYCLOAK_FRONTEND_URL') ?? '');
if ($configured !== '') {
return $configured;
}
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
$apiBase = rtrim(str_replace('\\', '/', dirname($script)), '/');
$root = preg_replace('#/api$#', '', $apiBase) ?: '';
return $scheme . '://' . $host . $root . '/website/index.html';
}
function qdb_keycloak_state_signing_key(): string
{
$secret = qdb_env_get('QDB_KEYCLOAK_STATE_SECRET')
?? qdb_env_get('QDB_MASTER_KEY')
?? qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET')
?? '';
if ($secret === '') {
throw new RuntimeException('No signing secret available for Keycloak state');
}
return $secret;
}
function qdb_keycloak_base64url(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
function qdb_keycloak_unbase64url(string $value): string
{
$padded = strtr($value, '-_', '+/');
$padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
$decoded = base64_decode($padded, true);
if ($decoded === false) {
throw new InvalidArgumentException('Invalid base64url value');
}
return $decoded;
}
function qdb_keycloak_create_state(string $nonce): string
{
$payload = json_encode([
'nonce' => $nonce,
'exp' => time() + 600,
], JSON_THROW_ON_ERROR);
$body = qdb_keycloak_base64url($payload);
$sig = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
return $body . '.' . $sig;
}
function qdb_keycloak_verify_state(string $state): array
{
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
}
[$body, $sig] = $parts;
$expected = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
if (!hash_equals($expected, $sig)) {
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
}
$payload = json_decode(qdb_keycloak_unbase64url($body), true);
if (!is_array($payload) || (int)($payload['exp'] ?? 0) < time()) {
json_error('INVALID_STATE', 'Expired Keycloak state', 400);
}
return $payload;
}
function qdb_keycloak_discovery(array $config): array
{
$url = $config['issuer'] . '/.well-known/openid-configuration';
$json = qdb_keycloak_http_json($url);
foreach (['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'] as $key) {
if (empty($json[$key])) {
throw new RuntimeException("Keycloak discovery missing $key");
}
}
return $json;
}
function qdb_keycloak_http_json(string $url, array $options = []): array
{
$context = stream_context_create(['http' => [
'method' => $options['method'] ?? 'GET',
'header' => $options['headers'] ?? '',
'content' => $options['content'] ?? '',
'timeout' => 10,
'ignore_errors' => true,
]]);
$raw = file_get_contents($url, false, $context);
if ($raw === false) {
throw new RuntimeException('Keycloak request failed');
}
$status = 200;
foreach (($http_response_header ?? []) as $header) {
if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) {
$status = (int)$m[1];
break;
}
}
$json = json_decode($raw, true);
if ($status < 200 || $status >= 300 || !is_array($json)) {
throw new RuntimeException('Keycloak returned an invalid response');
}
return $json;
}
function qdb_keycloak_exchange_code(array $config, array $discovery, string $code): array
{
$body = http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $config['redirect_uri'],
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
]);
return qdb_keycloak_http_json($discovery['token_endpoint'], [
'method' => 'POST',
'headers' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $body,
]);
}
function qdb_keycloak_userinfo(array $discovery, string $accessToken): array
{
return qdb_keycloak_http_json($discovery['userinfo_endpoint'], [
'headers' => "Authorization: Bearer $accessToken\r\n",
]);
}
function qdb_keycloak_claim_username(array $config, array $claims): string
{
$claim = $config['username_claim'] ?: 'preferred_username';
$username = trim((string)($claims[$claim] ?? ''));
if ($username === '' && $claim !== 'email') {
$username = trim((string)($claims['email'] ?? ''));
}
if ($username === '') {
json_error('KEYCLOAK_NO_USERNAME', 'University login did not provide a usable username', 403);
}
return $username;
}
function qdb_keycloak_redirect(string $url, int $status = 302): never
{
if (defined('QDB_TESTING')) {
throw new \Tests\Support\RawHttpResponse('', ['Location' => $url], $status);
}
http_response_code($status);
header('Location: ' . $url, true, $status);
exit;
}
function qdb_keycloak_finish_html(string $token, string $user, string $role): never
{
$target = qdb_keycloak_frontend_url() . '#/';
$payload = json_encode([
'token' => $token,
'user' => $user,
'role' => $role,
'target' => $target,
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR);
qdb_http_download(
"<!doctype html><meta charset=\"utf-8\"><script>const d=$payload;localStorage.setItem('qdb_token',d.token);localStorage.setItem('qdb_user',d.user);localStorage.setItem('qdb_role',d.role);location.replace(d.target);</script>",
['Content-Type' => 'text/html; charset=UTF-8']
);
}

176
lib/login_rate_limit.php Normal file
View File

@ -0,0 +1,176 @@
<?php
require_once __DIR__ . '/settings.php';
function qdb_login_rate_limit_path(): string
{
return dirname(QDB_PATH) . '/.login_rate_limit.json';
}
function qdb_client_ip(): string
{
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
if ($raw === '') {
return '0.0.0.0';
}
if (str_contains($raw, ',')) {
$raw = trim(explode(',', $raw)[0]);
}
return $raw;
}
function qdb_login_rate_limit_key(string $username): string
{
$ip = qdb_client_ip();
return hash('sha256', strtolower(trim($username)) . "\0" . $ip);
}
/**
* @return array{0: bool, 1: int} allowed, retry_after_seconds
*/
function qdb_login_rate_limit_check(string $username, ?array $settings = null): array
{
$settings ??= qdb_settings_get();
$max = (int)$settings['login_max_attempts'];
$window = (int)$settings['login_window_seconds'];
$lockout = (int)$settings['login_lockout_seconds'];
$now = time();
$key = qdb_login_rate_limit_key($username);
$path = qdb_login_rate_limit_path();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$fp = @fopen($path, 'c+');
if ($fp === false) {
return [true, 0];
}
try {
if (!flock($fp, LOCK_EX)) {
return [true, 0];
}
$raw = stream_get_contents($fp);
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
if (!is_array($data)) {
$data = [];
}
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
$lockedUntil = (int)($entry['locked_until'] ?? 0);
if ($lockedUntil > $now) {
return [false, $lockedUntil - $now];
}
$attempts = array_values(array_filter(
(array)($entry['attempts'] ?? []),
static fn($t) => is_int($t) && $t > $now - $window
));
if (count($attempts) >= $max) {
$entry['locked_until'] = $now + $lockout;
$entry['attempts'] = $attempts;
$data[$key] = $entry;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
fflush($fp);
return [false, $lockout];
}
return [true, 0];
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int
{
$settings ??= qdb_settings_get();
$max = (int)$settings['login_max_attempts'];
$window = (int)$settings['login_window_seconds'];
$lockout = (int)$settings['login_lockout_seconds'];
$now = time();
$key = qdb_login_rate_limit_key($username);
$path = qdb_login_rate_limit_path();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$fp = @fopen($path, 'c+');
if ($fp === false) {
return 0;
}
try {
if (!flock($fp, LOCK_EX)) {
return 0;
}
$raw = stream_get_contents($fp);
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
if (!is_array($data)) {
$data = [];
}
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
$attempts = array_values(array_filter(
(array)($entry['attempts'] ?? []),
static fn($t) => is_int($t) && $t > $now - $window
));
$attempts[] = $now;
$retry = 0;
if (count($attempts) >= $max) {
$entry['locked_until'] = $now + $lockout;
$retry = $lockout;
}
$entry['attempts'] = $attempts;
$data[$key] = $entry;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
fflush($fp);
return $retry;
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
function qdb_login_rate_limit_clear(string $username): void
{
$key = qdb_login_rate_limit_key($username);
$path = qdb_login_rate_limit_path();
if (!is_file($path)) {
return;
}
$fp = @fopen($path, 'c+');
if ($fp === false) {
return;
}
try {
if (!flock($fp, LOCK_EX)) {
return;
}
$raw = stream_get_contents($fp);
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
if (!is_array($data) || !isset($data[$key])) {
return;
}
unset($data[$key]);
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
fflush($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
function qdb_login_rate_limit_deny(int $retryAfterSeconds): never
{
if ($retryAfterSeconds > 0) {
header('Retry-After: ' . (string)$retryAfterSeconds);
}
json_error(
'RATE_LIMITED',
'Too many login attempts. Please wait before trying again.',
429
);
}

View File

@ -0,0 +1,414 @@
<?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)
ON CONFLICT(questionnaireID, structureRevision) DO UPDATE SET
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;
}
$chk = $pdo->query(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
);
if (!$chk || !$chk->fetchColumn()) {
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;
}
$chk = $pdo->query(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
);
if (!$chk || !$chk->fetchColumn()) {
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;
}

299
lib/read_db_cache.php Normal file
View File

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

122
lib/response.php Normal file
View File

@ -0,0 +1,122 @@
<?php
require_once __DIR__ . '/encrypted_payload.php';
/** @internal PHPUnit: inject request body (cleared by qdb_test_reset_http_state). */
function qdb_test_set_request_body(string $body): void
{
if (!defined('QDB_TESTING')) {
throw new RuntimeException('qdb_test_set_request_body is only available in tests');
}
$GLOBALS['qdb_test_request_body'] = $body;
}
/** @internal PHPUnit: reset cached body between API calls. */
function qdb_test_reset_http_state(): void
{
if (!defined('QDB_TESTING')) {
return;
}
unset($GLOBALS['qdb_test_request_body']);
}
/** @internal PHPUnit: rethrow response control-flow exceptions. */
function qdb_test_is_control_flow_exception(\Throwable $e): bool
{
return $e instanceof \Tests\Support\JsonSuccessResponse
|| $e instanceof \Tests\Support\JsonErrorResponse
|| $e instanceof \Tests\Support\RawHttpResponse;
}
function json_success(mixed $data, int $status = 200): never {
if (defined('QDB_TESTING')) {
throw new \Tests\Support\JsonSuccessResponse($data, $status);
}
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["ok" => true, "data" => $data]);
exit;
}
/**
* @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 read_json_body(): array {
$raw = qdb_raw_request_body();
$data = json_decode($raw, true);
if (!is_array($data)) {
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
}
return $data;
}
function read_encrypted_json_body(string $tokenHex): array {
$outer = read_json_body();
if (empty($outer['encrypted'])) {
json_error('ENCRYPTION_REQUIRED', 'Sensitive requests must send an encrypted payload', 400);
}
try {
$plain = qdb_decrypt_sensitive_envelope($outer, $tokenHex);
} catch (Throwable $e) {
error_log('decrypt body failed: ' . $e->getMessage());
json_error('DECRYPT_FAILED', 'Could not decrypt request payload', 400);
}
$data = json_decode($plain, true);
if (!is_array($data)) {
json_error('INVALID_BODY', 'Decrypted body must be valid JSON object', 400);
}
return $data;
}
function json_success_sensitive(mixed $data, string $tokenHex, int $status = 200): never {
$plain = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
json_success(qdb_sensitive_envelope($plain, $tokenHex), $status);
}

784
lib/scoring.php Normal file
View File

@ -0,0 +1,784 @@
<?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)
ON CONFLICT(clientCode, profileID) DO UPDATE SET
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,
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['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,
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['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, createdAt, updatedAt)
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => 'RHS Index',
':desc' => 'Legacy RHS thresholds (green 012, yellow 1336, red 37+)',
':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);
}
/**
* 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);
}

114
lib/settings.php Normal file
View File

@ -0,0 +1,114 @@
<?php
/**
* Persisted security settings (system_setting table in encrypted DB).
*/
function qdb_settings_defaults(): array
{
return [
'login_max_attempts' => 10,
'login_window_seconds' => 900,
'login_lockout_seconds' => 900,
'session_ttl_seconds' => 30 * 24 * 60 * 60,
'temp_session_ttl_seconds' => 10 * 60,
];
}
function qdb_settings_keys(): array
{
return array_keys(qdb_settings_defaults());
}
function qdb_ensure_system_setting_table(PDO $pdo): void
{
if (!qdb_table_exists($pdo, 'system_setting')) {
$pdo->exec("
CREATE TABLE system_setting (
settingKey TEXT NOT NULL PRIMARY KEY,
settingValue TEXT NOT NULL DEFAULT ''
)
");
}
}
function qdb_settings_get_on_pdo(PDO $pdo): array
{
qdb_ensure_system_setting_table($pdo);
$out = qdb_settings_defaults();
$rows = $pdo->query('SELECT settingKey, settingValue FROM system_setting')->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$key = (string)($row['settingKey'] ?? '');
if ($key === '' || !array_key_exists($key, $out)) {
continue;
}
$out[$key] = (int)$row['settingValue'];
}
return $out;
}
function qdb_settings_get(): array
{
require_once __DIR__ . '/../db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
return qdb_settings_get_on_pdo($pdo);
} finally {
qdb_discard($tmpDb, $lockFp);
}
}
/**
* @param array<string, int> $partial
* @param array<string, int>|null $base existing settings; defaults used when null
* @return array<string, int> saved settings
*/
function qdb_settings_validate_and_merge(array $partial, ?array $base = null): array
{
$merged = $base ?? qdb_settings_defaults();
$limits = [
'login_max_attempts' => [1, 100],
'login_window_seconds' => [60, 86400],
'login_lockout_seconds' => [60, 86400],
'session_ttl_seconds' => [3600, 365 * 24 * 60 * 60],
'temp_session_ttl_seconds' => [300, 24 * 60 * 60],
];
foreach (qdb_settings_keys() as $key) {
if (!array_key_exists($key, $partial)) {
continue;
}
$val = (int)$partial[$key];
[$min, $max] = $limits[$key];
if ($val < $min || $val > $max) {
throw new InvalidArgumentException(
"$key must be between $min and $max"
);
}
$merged[$key] = $val;
}
return $merged;
}
/**
* @param array<string, int> $settings full merged settings to persist
*/
function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void
{
qdb_ensure_system_setting_table($pdo);
$stmt = $pdo->prepare(
'INSERT INTO system_setting (settingKey, settingValue)
VALUES (:k, :v)
ON CONFLICT(settingKey) DO UPDATE SET 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'];
}

999
lib/submissions.php Normal file
View File

@ -0,0 +1,999 @@
<?php
/**
* Questionnaire submission versioning (archive each coach upload).
*/
require_once __DIR__ . '/questionnaire_structure.php';
/**
* Build display columns from a stored structure manifest (historical submissions).
*
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
*/
function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
$stringLabelCache = [];
$resultColumns = [];
$questions = [];
$optionTextMap = [];
foreach ($manifest['questions'] ?? [] as $qEntry) {
if (!is_array($qEntry)) {
continue;
}
$fullId = (string)($qEntry['questionID'] ?? '');
if ($fullId === '') {
continue;
}
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionID = :id'
);
$qStmt->execute([':id' => $fullId]);
$qRow = $qStmt->fetch(PDO::FETCH_ASSOC);
if (!$qRow) {
$symptoms = $qEntry['symptoms'] ?? [];
$qRow = [
'questionID' => $fullId,
'defaultText' => (string)($qEntry['questionKey'] ?? $qEntry['shortId'] ?? $fullId),
'type' => (string)($qEntry['type'] ?? ''),
'orderIndex' => 0,
'configJson' => json_encode(
['symptoms' => is_array($symptoms) ? $symptoms : []],
JSON_UNESCAPED_UNICODE
),
];
}
$questions[] = $qRow;
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$type = $qRow['type'] ?? '';
if ($type === 'glass_scale_question') {
$symptoms = $cfg['symptoms'] ?? $qEntry['symptoms'] ?? [];
if (is_array($symptoms) && $symptoms !== []) {
foreach ($symptoms as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
$resultColumns[] = [
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
'questionID' => $fullId,
'symptomKey' => $sk,
'kind' => 'glass_symptom',
'question' => $qRow,
];
}
continue;
}
}
$resultColumns[] = [
'header' => qdb_question_german_label($pdo, $qRow),
'questionID' => $fullId,
'symptomKey' => null,
'kind' => 'question',
'question' => $qRow,
];
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
$aoStmt->execute([':qid' => $fullId]);
$dbOptions = $aoStmt->fetchAll(PDO::FETCH_ASSOC);
if ($dbOptions !== []) {
foreach ($dbOptions as $ao) {
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
$pdo,
$ao['answerOptionID'],
$ao['defaultText']
);
}
} else {
foreach ($qEntry['options'] ?? [] as $opt) {
if (!is_array($opt)) {
continue;
}
$key = (string)($opt['key'] ?? '');
if ($key !== '') {
$optionTextMap[$key] = $key;
}
}
}
}
return [
'questions' => $questions,
'resultColumns' => $resultColumns,
'optionTextMap' => $optionTextMap,
'stringLabelCache' => $stringLabelCache,
];
}
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
$stmt = $pdo->prepare(
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn'
);
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
return (int)$stmt->fetchColumn();
}
/**
* Snapshot live answers + completion metadata after a successful submit.
*/
function qdb_record_submission_after_submit(
PDO $pdo,
string $clientCode,
string $questionnaireID,
array $tokenRec,
?int $startedAt,
?int $completedAt,
int $sumPoints,
string $assignedByCoach,
int $structureRevision = 1,
?array $structureManifest = null,
?array $questionIdsToCopy = null,
?int $submittedAt = null
): string {
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
$submissionID = bin2hex(random_bytes(16));
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
$snapshotJson = '{}';
if ($structureManifest !== null) {
$encoded = json_encode($structureManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded !== false) {
$snapshotJson = $encoded;
}
}
$cq = $pdo->prepare(
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
);
$cq->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
$status = $cq->fetchColumn() ?: 'completed';
$pdo->prepare(
'INSERT INTO questionnaire_submission (
submissionID, clientCode, questionnaireID, version, submittedAt,
submittedByUserID, submittedByRole, assignedByCoach,
status, startedAt, completedAt, sumPoints,
structureRevision, structureSnapshotJson
) VALUES (
:sid, :cc, :qn, :ver, :sat,
:uid, :role, :abc,
:st, :sa, :ca, :sp,
:srev, :snap
)'
)->execute([
':sid' => $submissionID,
':cc' => $clientCode,
':qn' => $questionnaireID,
':ver' => $version,
':sat' => $submittedAtValue,
':uid' => $tokenRec['userID'] ?? '',
':role' => $tokenRec['role'] ?? '',
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
':st' => $status,
':sa' => $startedAt,
':ca' => $completedAt,
':sp' => $sumPoints,
':srev' => max(1, $structureRevision),
':snap' => $snapshotJson,
]);
if ($questionIdsToCopy !== null && $questionIdsToCopy !== []) {
$questionIdsToCopy = array_values(array_unique(array_filter($questionIdsToCopy, 'is_string')));
$ph = implode(',', array_fill(0, count($questionIdsToCopy), '?'));
$copy = $pdo->prepare(
"INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT ?, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($ph)"
);
$copy->execute(array_merge([$submissionID, $clientCode], $questionIdsToCopy));
} else {
$copy = $pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = :cc AND questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :qn
)'
);
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
}
return $submissionID;
}
/**
* One-time: create version 1 submissions for existing completions without archive rows.
*/
function qdb_backfill_submissions_from_live(PDO $pdo): bool {
$count = (int)$pdo->query('SELECT COUNT(*) FROM questionnaire_submission')->fetchColumn();
if ($count > 0) {
return false;
}
$rows = $pdo->query(
'SELECT clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints
FROM completed_questionnaire'
)->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
return false;
}
$pdo->beginTransaction();
try {
foreach ($rows as $cq) {
$clientCode = $cq['clientCode'];
$qnID = $cq['questionnaireID'];
$chk = $pdo->prepare(
'SELECT 1 FROM client_answer ca
JOIN question q ON q.questionID = ca.questionID
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn LIMIT 1'
);
$chk->execute([':cc' => $clientCode, ':qn' => $qnID]);
if (!$chk->fetchColumn()) {
continue;
}
$submissionID = bin2hex(random_bytes(16));
$completedAt = $cq['completedAt'] ?? time();
$pdo->prepare(
'INSERT INTO questionnaire_submission (
submissionID, clientCode, questionnaireID, version, submittedAt,
submittedByUserID, submittedByRole, assignedByCoach,
status, startedAt, completedAt, sumPoints
) VALUES (
:sid, :cc, :qn, 1, :sat,
\'\', \'\', :abc,
:st, :sa, :ca, :sp
)'
)->execute([
':sid' => $submissionID,
':cc' => $clientCode,
':qn' => $qnID,
':sat' => $completedAt,
':abc' => $cq['assignedByCoach'],
':st' => $cq['status'] ?: 'completed',
':sa' => $cq['startedAt'],
':ca' => $cq['completedAt'],
':sp' => $cq['sumPoints'],
]);
$pdo->prepare(
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = :cc AND questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :qn
)'
)->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $qnID]);
}
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/**
* Build CSV rows for all submission versions of one questionnaire (RBAC-scoped).
*
* @return list<array<string, string>>
*/
function qdb_export_all_versions_rows(
PDO $pdo,
string $questionnaireID,
array $questions,
array $resultColumns,
array $optionTextMap,
array $tokenRec
): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
qs.structureRevision, qs.structureSnapshotJson,
cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE qs.questionnaireID = :qnid AND ($rbacClause)
ORDER BY cl.clientCode ASC, qs.version ASC
";
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$defaultQuestionIDs = array_column($questions, 'questionID');
$userMap = [];
$userStmt = $pdo->query('SELECT userID, username FROM users');
foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) {
$userMap[$u['userID']] = $u['username'];
}
$rows = [];
foreach ($submissions as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$submissionColumns = $snapCtx['resultColumns'];
$submissionOptionMap = $snapCtx['optionTextMap'];
$submissionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$submissionColumns = $resultColumns;
$submissionOptionMap = $optionTextMap;
$submissionQuestionIDs = $defaultQuestionIDs;
}
$row = [
'submissionID' => $s['submissionID'],
'version' => (string)(int)$s['version'],
'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'submittedByRole' => $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
'clientCode' => $s['clientCode'],
'coach' => $s['coachUsername'] ?? $s['coachID'],
'supervisor' => $s['supervisorUsername'] ?? '',
'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] ?? '',
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
];
$answerMap = qdb_load_client_answer_map(
$pdo,
$s['clientCode'],
$submissionQuestionIDs,
'client_answer_submission',
$s['submissionID']
);
foreach ($submissionColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $submissionOptionMap);
}
$rows[] = $row;
}
return $rows;
}
/**
* @return array{questions: array, resultColumns: array, optionTextMap: array}
*/
function qdb_export_questionnaire_context(PDO $pdo, string $questionnaireID): array {
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex'
);
$qStmt->execute([':id' => $questionnaireID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
$resultColumns = qdb_results_export_columns($questions, $questionnaireID);
$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'];
}
}
return [
'questions' => $questions,
'resultColumns' => $resultColumns,
'optionTextMap' => $optionTextMap,
];
}
/**
* Current (live) response rows for one questionnaire, RBAC-scoped.
*
* @return list<array<string, string>>
*/
function qdb_export_current_rows(
PDO $pdo,
string $questionnaireID,
array $questions,
array $resultColumns,
array $optionTextMap,
array $tokenRec
): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$questionIDs = array_column($questions, 'questionID');
$sql = "
SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause
ORDER BY cl.clientCode
";
$params = array_merge([':qnid' => $questionnaireID], $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'],
'coach' => $c['coachUsername'] ?? $c['coachID'],
'supervisor' => $c['supervisorUsername'] ?? '',
'status' => $c['status'],
'sumPoints' => $c['sumPoints'],
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
];
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answerMap = [];
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
$answerMap[$a['questionID']] = $a;
}
foreach ($resultColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
}
$rows[] = $row;
}
return $rows;
}
function qdb_export_safe_basename(string $name): string {
$safe = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $name);
return $safe !== '' ? $safe : 'questionnaire';
}
/** @param array<string, true> $used */
function qdb_export_unique_zip_name(array &$used, string $base): string {
$name = $base;
$n = 2;
while (isset($used[$name])) {
$name = preg_replace('/(\.csv)$/i', "_{$n}$1", $base, 1, $count);
if (!$count) {
$name = $base . '_' . $n;
}
$n++;
}
$used[$name] = true;
return $name;
}
/**
* @param list<array<string, string>> $rows
* @param list<string> $fallbackHeader
*/
function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): string {
$fp = fopen('php://temp', 'r+');
if ($fp === false) {
return '';
}
fwrite($fp, "\xEF\xBB\xBF");
if (!empty($rows)) {
fputcsv($fp, array_keys($rows[0]), ',', '"', '\\');
foreach ($rows as $row) {
fputcsv($fp, array_values($row), ',', '"', '\\');
}
} elseif ($fallbackHeader !== []) {
fputcsv($fp, $fallbackHeader, ',', '"', '\\');
}
rewind($fp);
$csv = stream_get_contents($fp);
fclose($fp);
return $csv !== false ? $csv : '';
}
function qdb_export_current_csv_fallback_header(array $resultColumns): array {
$header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($resultColumns as $col) {
$header[] = $col['header'];
}
return $header;
}
function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
$header = [
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy',
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt',
];
foreach ($resultColumns as $col) {
$header[] = $col['header'];
}
return $header;
}
/**
* Build a ZIP of CSV exports for every questionnaire. Returns path to a temp file (caller must unlink).
*/
function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersions): string {
if (!class_exists('ZipArchive')) {
json_error('SERVER_ERROR', 'ZIP export is not available on this server', 500);
}
$qnRows = $pdo->query(
'SELECT questionnaireID, name, version FROM questionnaire ORDER BY orderIndex, name'
)->fetchAll(PDO::FETCH_ASSOC);
$tmpZip = tempnam(sys_get_temp_dir(), 'qdb_export_');
if ($tmpZip === false) {
json_error('SERVER_ERROR', 'Could not create export file', 500);
}
$zip = new ZipArchive();
if ($zip->open($tmpZip, ZipArchive::OVERWRITE) !== true) {
@unlink($tmpZip);
json_error('SERVER_ERROR', 'Could not create ZIP archive', 500);
}
$usedNames = [];
foreach ($qnRows as $qn) {
$qnID = $qn['questionnaireID'];
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
$questions = $ctx['questions'];
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
if ($allVersions) {
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
$fallback = qdb_export_all_versions_csv_fallback_header($resultColumns);
$base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv';
} else {
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
$fallback = qdb_export_current_csv_fallback_header($resultColumns);
$ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? ''));
$base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv';
}
$entryName = qdb_export_unique_zip_name($usedNames, $base);
$csv = qdb_export_rows_to_csv_string($rows, $fallback);
$zip->addFromString($entryName, $csv);
}
$readme = $allVersions
? "NAT-AS Ressourcenbarometer — all questionnaire response exports (every upload version).\nOne CSV per questionnaire.\n"
: "NAT-AS Ressourcenbarometer — all questionnaire response exports (current data).\nOne CSV per questionnaire.\n";
$zip->addFromString('README.txt', $readme);
$zip->close();
return $tmpZip;
}
/**
* Delete uploads, answers, and completions for client codes (does not delete client rows).
*
* @param list<string> $clientCodes
* @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int}
*/
function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
$clientCodes = array_values(array_unique(array_filter(
$clientCodes,
static fn($c) => is_string($c) && $c !== ''
)));
$deleted = [
'submissions' => 0,
'followup_notes' => 0,
'client_answers' => 0,
'completed_questionnaires' => 0,
];
if ($clientCodes === []) {
return $deleted;
}
foreach (array_chunk($clientCodes, 200) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '?'));
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['submissions'] += $stmt->rowCount();
}
if (qdb_table_exists($pdo, 'client_followup_note')) {
$stmt = $pdo->prepare("DELETE FROM client_followup_note WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['followup_notes'] += $stmt->rowCount();
}
$stmt = $pdo->prepare("DELETE FROM client_answer WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['client_answers'] += $stmt->rowCount();
$stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
$stmt->execute($chunk);
$deleted['completed_questionnaires'] += $stmt->rowCount();
}
return $deleted;
}
/**
* Remove submission rows assigned to coaches (FK before coach delete).
*
* @param list<string> $coachIDs
*/
function qdb_delete_submissions_for_coaches(PDO $pdo, array $coachIDs): int {
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
return 0;
}
$coachIDs = array_values(array_unique(array_filter(
$coachIDs,
static fn($id) => is_string($id) && $id !== ''
)));
if ($coachIDs === []) {
return 0;
}
$total = 0;
foreach (array_chunk($coachIDs, 200) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '?'));
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE assignedByCoach IN ($ph)");
$stmt->execute($chunk);
$total += $stmt->rowCount();
}
return $total;
}
/**
* @param array<string, array<string, mixed>> $answerMap questionID => answer row
* @param array<string, array<string, mixed>>|null $compareMap live answers to diff against
* @return list<array{label: string, value: string, changedFromLive: bool}>
*/
function qdb_answers_display_rows(
PDO $pdo,
array $resultColumns,
array $answerMap,
array $optionTextMap,
array &$stringLabelCache,
?array $compareMap = null
): array {
$rows = [];
foreach ($resultColumns as $col) {
$qid = $col['questionID'];
$a = $answerMap[$qid] ?? null;
$value = qdb_display_column_cell_value($pdo, $col, $a, $optionTextMap, $stringLabelCache);
$changedFromLive = false;
if ($compareMap !== null) {
$liveValue = qdb_display_column_cell_value(
$pdo,
$col,
$compareMap[$qid] ?? null,
$optionTextMap,
$stringLabelCache
);
$changedFromLive = $value !== $liveValue;
}
$rows[] = [
'label' => $col['header'],
'value' => $value,
'changedFromLive' => $changedFromLive,
];
}
return $rows;
}
/**
* @param list<string> $questionIDs
* @return array<string, array<string, mixed>>
*/
function qdb_load_client_answer_map(
PDO $pdo,
string $clientCode,
array $questionIDs,
string $table = 'client_answer',
?string $submissionID = null
): array {
if ($questionIDs === []) {
return [];
}
$allowed = ['client_answer', 'client_answer_submission'];
if (!in_array($table, $allowed, true)) {
return [];
}
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
if ($table === 'client_answer_submission') {
if ($submissionID === null || $submissionID === '') {
return [];
}
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer_submission
WHERE submissionID = ? AND questionID IN ($ph)";
$params = array_merge([$submissionID], $questionIDs);
} else {
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
FROM client_answer
WHERE clientCode = ? AND questionID IN ($ph)";
$params = array_merge([$clientCode], $questionIDs);
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$map = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$map[$row['questionID']] = $row;
}
return $map;
}
/**
* Full client response detail for the web clients list (RBAC-scoped).
*/
function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array {
$clientCode = trim($clientCode);
if ($clientCode === '') {
json_error('INVALID_FIELD', 'clientCode is required', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl', 'all');
$stmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
co.username AS coachUsername,
sv.username AS supervisorUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE cl.clientCode = :cc AND $rbacClause"
);
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
$client = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$client) {
json_error('NOT_FOUND', 'Client not found', 404);
}
$qnStmt = $pdo->prepare(
"SELECT q.questionnaireID, q.name, q.version AS questionnaireVersion, q.orderIndex
FROM questionnaire q
WHERE q.questionnaireID IN (
SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc
UNION
SELECT questionnaireID FROM questionnaire_submission WHERE clientCode = :cc2
)
ORDER BY q.orderIndex, q.name"
);
$qnStmt->execute([':cc' => $clientCode, ':cc2' => $clientCode]);
$qnRows = $qnStmt->fetchAll(PDO::FETCH_ASSOC);
$questionnaires = [];
foreach ($qnRows as $qn) {
$qnID = $qn['questionnaireID'];
$ctx = qdb_display_questionnaire_context($pdo, $qnID);
$questionIDs = array_column($ctx['questions'], 'questionID');
$resultColumns = $ctx['resultColumns'];
$optionTextMap = $ctx['optionTextMap'];
$stringLabelCache = $ctx['stringLabelCache'];
$cqStmt = $pdo->prepare(
'SELECT status, sumPoints, startedAt, completedAt
FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn'
);
$cqStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$cq = $cqStmt->fetch(PDO::FETCH_ASSOC) ?: [];
$liveMap = qdb_load_client_answer_map($pdo, $clientCode, $questionIDs);
$currentAnswers = qdb_answers_display_rows(
$pdo,
$resultColumns,
$liveMap,
$optionTextMap,
$stringLabelCache,
null
);
$subStmt = $pdo->prepare(
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt,
structureRevision, structureSnapshotJson
FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn
ORDER BY version DESC'
);
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$submissions = [];
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
if (!empty($snap['questions'])) {
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
$versionColumns = $snapCtx['resultColumns'];
$versionOptionMap = $snapCtx['optionTextMap'];
$versionStringCache = $snapCtx['stringLabelCache'];
$versionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
} else {
$versionColumns = $resultColumns;
$versionOptionMap = $optionTextMap;
$versionStringCache = $stringLabelCache;
$versionQuestionIDs = $questionIDs;
}
$subMap = qdb_load_client_answer_map(
$pdo,
$clientCode,
$versionQuestionIDs,
'client_answer_submission',
$s['submissionID']
);
$versionAnswers = qdb_answers_display_rows(
$pdo,
$versionColumns,
$subMap,
$versionOptionMap,
$versionStringCache,
$liveMap
);
$changedCount = count(array_filter(
$versionAnswers,
static fn($r) => !empty($r['changedFromLive'])
));
$submissions[] = [
'submissionID' => $s['submissionID'],
'version' => (int)$s['version'],
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
'status' => $s['status'] ?? '',
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
'changedCount' => $changedCount,
'answers' => $versionAnswers,
];
}
$questionnaires[] = [
'questionnaireID' => $qnID,
'name' => $qn['name'],
'questionnaireVersion' => $qn['questionnaireVersion'] ?? '',
'status' => $cq['status'] ?? '',
'sumPoints' => isset($cq['sumPoints']) ? (int)$cq['sumPoints'] : null,
'startedAt' => !empty($cq['startedAt']) ? date('Y-m-d H:i', (int)$cq['startedAt']) : '',
'completedAt' => !empty($cq['completedAt']) ? date('Y-m-d H:i', (int)$cq['completedAt']) : '',
'submissionCount' => count($submissions),
'currentAnswers' => $currentAnswers,
'submissions' => $submissions,
];
}
return [
'client' => [
'clientCode' => $client['clientCode'],
'coachID' => $client['coachID'] ?? '',
'coachUsername' => $client['coachUsername'] ?? '',
'supervisorUsername' => $client['supervisorUsername'] ?? '',
],
'questionnaires' => $questionnaires,
'scoringProfiles' => qdb_client_scoring_profile_results($pdo, $clientCode),
];
}
function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array {
require_once __DIR__ . '/scoring.php';
$stmt = $pdo->prepare(
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc
ORDER BY sp.name'
);
$stmt->execute([':cc' => $clientCode]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bands = qdb_normalize_scoring_bands($row);
$coachBand = trim((string)($row['coachBand'] ?? ''));
$out[] = [
'profileID' => $row['profileID'],
'name' => $row['name'],
'weightedTotal' => (float)$row['weightedTotal'],
'band' => $row['band'],
'calculatedBand' => $row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'greenMin' => $bands['greenMin'],
'greenMax' => $bands['greenMax'],
'yellowMin' => $bands['yellowMin'],
'yellowMax' => $bands['yellowMax'],
'redMin' => $bands['redMin'],
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
'coachReviewedAt' => !empty($row['coachReviewedAt'])
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
];
}
return $out;
}
/**
* Active scoring profiles + per-client results for the clients list (RBAC-scoped).
*
* @return array{profiles: list<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
*/
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
if ($profiles === []) {
return ['profiles' => [], 'byClient' => []];
}
$stmt = $pdo->prepare(
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
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
WHERE ($rbacClause)"
);
$stmt->execute($rbacParams);
$byClient = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode'];
$coachBand = trim((string)($row['coachBand'] ?? ''));
$byClient[$cc][$row['profileID']] = [
'band' => (string)$row['band'],
'calculatedBand' => (string)$row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'weightedTotal' => (float)$row['weightedTotal'],
'name' => (string)$row['name'],
];
}
return [
'profiles' => array_map(static fn(array $p) => [
'profileID' => (string)$p['profileID'],
'name' => (string)$p['name'],
], $profiles),
'byClient' => $byClient,
];
}
/**
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
*/
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
$out = [];
foreach ($summary['profiles'] as $profile) {
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
$out[] = [
'profileID' => $profile['profileID'],
'name' => $profile['name'],
'band' => $result['calculatedBand'] ?? null,
'calculatedBand' => $result['calculatedBand'] ?? null,
'coachBand' => $result['coachBand'] ?? null,
'effectiveBand' => $result['effectiveBand'] ?? null,
'pendingReview' => $result['pendingReview'] ?? false,
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
];
}
return $out;
}

61
lib/text_sanitize.php Normal file
View File

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

71
lib/validate.php Normal file
View File

@ -0,0 +1,71 @@
<?php
/**
* Validate that all required fields are present and non-empty in the body.
* Calls json_error on failure (never returns in that case).
*/
function require_fields(array $body, array $fields): void {
$missing = [];
foreach ($fields as $f) {
if (!isset($body[$f]) || (is_string($body[$f]) && trim($body[$f]) === '')) {
$missing[] = $f;
}
}
if ($missing) {
json_error('MISSING_FIELDS', 'Required fields: ' . implode(', ', $missing), 400);
}
}
/**
* Validate a string value with optional min/max length.
* Returns the trimmed string or calls json_error.
*/
function validate_string(mixed $value, string $fieldName, int $min = 0, int $max = 0): string {
if (!is_string($value) && !is_numeric($value)) {
json_error('INVALID_FIELD', "$fieldName must be a string", 400);
}
$s = trim((string)$value);
if ($min > 0 && strlen($s) < $min) {
json_error('INVALID_FIELD', "$fieldName must be at least $min characters", 400);
}
if ($max > 0 && strlen($s) > $max) {
json_error('INVALID_FIELD', "$fieldName must be at most $max characters", 400);
}
return $s;
}
/**
* Validate that a value is one of the allowed options.
* Returns the value or calls json_error.
*/
function validate_enum(mixed $value, string $fieldName, array $allowed): string {
$s = trim((string)$value);
if (!in_array($s, $allowed, true)) {
json_error('INVALID_FIELD', "$fieldName must be one of: " . implode(', ', $allowed), 400);
}
return $s;
}
/**
* Validate and return an integer from body, with optional min/max bounds.
*/
function validate_int(mixed $value, string $fieldName, ?int $min = null, ?int $max = null): int {
$i = (int)$value;
if ($min !== null && $i < $min) {
json_error('INVALID_FIELD', "$fieldName must be at least $min", 400);
}
if ($max !== null && $i > $max) {
json_error('INVALID_FIELD', "$fieldName must be at most $max", 400);
}
return $i;
}
/**
* Require the request method to match, or exit with 405.
*/
function require_method(string ...$allowed): void {
$method = $_SERVER['REQUEST_METHOD'];
if (!in_array($method, $allowed, true)) {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
}

0
logs/api/.gitkeep Normal file
View File

30
phpunit.xml Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<!-- common.php is legacy/shared helpers; coverage focuses on API surface (lib, handlers, db_init). -->
<include>
<directory suffix=".php">lib</directory>
<directory suffix=".php">handlers</directory>
<file>db_init.php</file>
<file>common.php</file>
</include>
<exclude>
<directory>tests</directory>
<directory>vendor</directory>
</exclude>
</source>
</phpunit>

259
schema.sql Normal file
View File

@ -0,0 +1,259 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
userID TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
passwordHash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','supervisor','coach')),
entityID TEXT NOT NULL,
mustChangePassword INTEGER NOT NULL DEFAULT 1,
createdAt INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS admin (
adminID TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL,
location TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS supervisor (
supervisorID TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL,
location TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS coach (
coachID TEXT NOT NULL PRIMARY KEY,
supervisorID TEXT NOT NULL,
username TEXT NOT NULL,
FOREIGN KEY(supervisorID) REFERENCES supervisor(supervisorID)
);
CREATE TABLE IF NOT EXISTS client (
clientCode TEXT NOT NULL PRIMARY KEY,
coachID TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
archivedAt INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(coachID) REFERENCES coach(coachID)
);
CREATE TABLE IF NOT EXISTS questionnaire_category (
categoryKey TEXT NOT NULL PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#64748b',
orderIndex INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS questionnaire (
questionnaireID TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
orderIndex INTEGER NOT NULL DEFAULT 0,
showPoints INTEGER NOT NULL DEFAULT 0,
conditionJson TEXT NOT NULL DEFAULT '{}',
categoryKey TEXT NOT NULL DEFAULT '',
structureRevision INTEGER NOT NULL DEFAULT 1,
structureChangedAt INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS questionnaire_structure_snapshot (
questionnaireID TEXT NOT NULL,
structureRevision INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
manifestJson TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (questionnaireID, structureRevision),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS question (
questionID TEXT NOT NULL PRIMARY KEY,
questionnaireID TEXT NOT NULL,
defaultText TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
orderIndex INTEGER NOT NULL DEFAULT 0,
isRequired INTEGER NOT NULL DEFAULT 0,
configJson TEXT NOT NULL DEFAULT '{}',
retiredAt INTEGER NOT NULL DEFAULT 0,
retiredInRevision INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
);
CREATE TABLE IF NOT EXISTS answer_option (
answerOptionID TEXT NOT NULL PRIMARY KEY,
questionID TEXT NOT NULL,
defaultText TEXT NOT NULL DEFAULT '',
points INTEGER NOT NULL DEFAULT 0,
orderIndex INTEGER NOT NULL DEFAULT 0,
nextQuestionId TEXT NOT NULL DEFAULT '',
retiredAt INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID)
);
CREATE TABLE IF NOT EXISTS client_answer (
clientCode TEXT NOT NULL,
questionID TEXT NOT NULL,
answerOptionID TEXT,
freeTextValue TEXT,
numericValue REAL,
answeredAt INTEGER,
PRIMARY KEY (clientCode, questionID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionID) REFERENCES question(questionID),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
);
CREATE TABLE IF NOT EXISTS completed_questionnaire (
clientCode TEXT NOT NULL,
questionnaireID TEXT NOT NULL,
assignedByCoach TEXT,
status TEXT NOT NULL DEFAULT '',
startedAt INTEGER,
completedAt INTEGER,
sumPoints INTEGER,
PRIMARY KEY (clientCode, questionnaireID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID)
);
CREATE TABLE IF NOT EXISTS question_translation (
questionID TEXT NOT NULL,
languageCode TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (questionID, languageCode),
FOREIGN KEY(questionID) REFERENCES question(questionID)
);
CREATE TABLE IF NOT EXISTS answer_option_translation (
answerOptionID TEXT NOT NULL,
languageCode TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (answerOptionID, languageCode),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
);
CREATE TABLE IF NOT EXISTS string_translation (
stringKey TEXT NOT NULL,
languageCode TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (stringKey, languageCode)
);
CREATE TABLE IF NOT EXISTS language (
languageCode TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS questionnaire_submission (
submissionID TEXT NOT NULL PRIMARY KEY,
clientCode TEXT NOT NULL,
questionnaireID TEXT NOT NULL,
version INTEGER NOT NULL,
submittedAt INTEGER NOT NULL,
submittedByUserID TEXT NOT NULL DEFAULT '',
submittedByRole TEXT NOT NULL DEFAULT '',
assignedByCoach TEXT,
status TEXT NOT NULL DEFAULT '',
startedAt INTEGER,
completedAt INTEGER,
sumPoints INTEGER,
structureRevision INTEGER NOT NULL DEFAULT 1,
structureSnapshotJson TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
UNIQUE(clientCode, questionnaireID, version)
);
CREATE INDEX IF NOT EXISTS idx_submission_client_qn
ON questionnaire_submission(clientCode, questionnaireID);
CREATE INDEX IF NOT EXISTS idx_submission_client_time
ON questionnaire_submission(clientCode, submittedAt);
CREATE TABLE IF NOT EXISTS client_answer_submission (
submissionID TEXT NOT NULL,
questionID TEXT NOT NULL,
answerOptionID TEXT,
freeTextValue TEXT,
numericValue REAL,
answeredAt INTEGER,
PRIMARY KEY (submissionID, questionID),
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
FOREIGN KEY(questionID) REFERENCES question(questionID),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
);
CREATE TABLE IF NOT EXISTS client_followup_note (
clientCode TEXT NOT NULL PRIMARY KEY,
note TEXT NOT NULL DEFAULT '',
updatedByUserID TEXT NOT NULL DEFAULT '',
updatedAt INTEGER NOT NULL,
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
);
CREATE TABLE IF NOT EXISTS question_score_rule (
ruleID TEXT NOT NULL PRIMARY KEY,
questionID TEXT NOT NULL,
scopeKey TEXT NOT NULL DEFAULT '',
levelKey TEXT NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
UNIQUE(questionID, scopeKey, levelKey)
);
CREATE INDEX IF NOT EXISTS idx_score_rule_question
ON question_score_rule(questionID);
CREATE TABLE IF NOT EXISTS scoring_profile (
profileID TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
isActive INTEGER NOT NULL DEFAULT 1,
greenMin INTEGER NOT NULL DEFAULT 0,
greenMax INTEGER NOT NULL DEFAULT 12,
yellowMin INTEGER NOT NULL DEFAULT 13,
yellowMax INTEGER NOT NULL DEFAULT 36,
redMin INTEGER NOT NULL DEFAULT 37,
createdAt INTEGER NOT NULL DEFAULT 0,
updatedAt INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS scoring_profile_questionnaire (
profileID TEXT NOT NULL,
questionnaireID TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
orderIndex INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (profileID, questionnaireID),
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS client_scoring_profile_result (
clientCode TEXT NOT NULL,
profileID TEXT NOT NULL,
weightedTotal REAL NOT NULL DEFAULT 0,
band TEXT NOT NULL DEFAULT '',
computedAt INTEGER NOT NULL DEFAULT 0,
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
coachBand TEXT NOT NULL DEFAULT '',
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
PRIMARY KEY (clientCode, profileID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile
ON client_scoring_profile_result(profileID);

View File

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Generate dev-test-fixture.json for admin Dev tab import."""
import json
import random
from datetime import datetime, timedelta
from pathlib import Path
PREFIX = "dev_"
PASSWORD = "socialvrlab"
CLIENT_PREFIX = "DEV-CL-"
SCALE = 5
# Fixed RNG + anchor time so the file is reproducible but not grid-like.
RNG = random.Random(42)
ANCHOR = datetime(2026, 5, 27, 15, 30, 0)
REPO_ROOT = Path(__file__).resolve().parents[2]
FALLBACK_QUESTIONNAIRE_IDS = [
"questionnaire_1_demographic_information",
"questionnaire_2_rhs",
"questionnaire_3_integration_index",
"questionnaire_4_consultation_results",
"questionnaire_5_final_interview",
"questionnaire_6_follow_up_survey",
]
NUM_ADMINS = 2 * SCALE
NUM_SUPERVISORS = 3 * SCALE
NUM_COACHES = 20 * SCALE
NUM_CLIENTS = 100 * SCALE
CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE
# Roughly how many clients (with a coach) get at least one questionnaire.
COMPLETION_PARTICIPATION = 0.82
def resolve_bundle_path() -> Path | None:
"""Use the newest questionnaires_bundle_*.json in the repo root."""
candidates = sorted(
REPO_ROOT.glob("questionnaires_bundle_*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
return candidates[0] if candidates else None
def load_questionnaire_ids(bundle_path: Path | None) -> list[str]:
if bundle_path is None or not bundle_path.is_file():
return FALLBACK_QUESTIONNAIRE_IDS
bundle = json.loads(bundle_path.read_text(encoding="utf-8"))
ids = [
item["questionnaire"]["questionnaireID"]
for item in bundle.get("questionnaires", [])
if item.get("questionnaire", {}).get("questionnaireID")
]
return ids or FALLBACK_QUESTIONNAIRE_IDS
def ts_days_ago(min_days: float, max_days: float) -> int:
days = RNG.uniform(min_days, max_days)
return int((ANCHOR - timedelta(days=days)).timestamp())
def variant_seed(client_code: str, qn_id: str, version_index: int) -> int:
raw = f"{client_code}:{qn_id}:v{version_index}"
return int.from_bytes(raw.encode(), "big") % 100_000
def assign_coaches(clients: list[dict], coaches: list[dict]) -> None:
"""Uneven coach load: a few busy coaches, several light ones."""
coach_names = [c["username"] for c in coaches]
weights = []
for i, _ in enumerate(coach_names):
# Coaches 14 and 7, 12 get more clients; tail is lighter.
base = RNG.uniform(0.4, 1.0)
if (i % 7) in (0, 1, 2):
base *= RNG.uniform(2.0, 4.5)
if i % 11 == 0:
base *= RNG.uniform(0.15, 0.45)
weights.append(base)
for row in clients:
row["coach"] = RNG.choices(coach_names, weights=weights, k=1)[0]
def build_completions(
questionnaire_ids: list[str],
clients_with_data: list[str],
) -> list[dict]:
"""
Natural-ish completion patterns:
- Sequential questionnaire funnel (not random qn per slot)
- Irregular timestamps and gaps between uploads
- ~20% of client/qn pairs have 24 archived versions
"""
completions: list[dict] = []
qn_count = len(questionnaire_ids)
# How many questionnaires a client completes (skewed toward mid funnel).
qn_count_weights = [8, 14, 22, 24, 18, 10, 4][:qn_count]
if len(qn_count_weights) < qn_count:
qn_count_weights += [2] * (qn_count - len(qn_count_weights))
for client_code in clients_with_data:
if RNG.random() > COMPLETION_PARTICIPATION:
continue
num_qn = RNG.choices(
list(range(1, qn_count + 1)),
weights=qn_count_weights[:qn_count],
k=1,
)[0]
selected = questionnaire_ids[:num_qn]
# First activity somewhere in the last ~6 months; some clients are very recent.
cursor = ts_days_ago(3, 185)
if RNG.random() < 0.12:
cursor = ts_days_ago(0.5, 14)
for qn_id in selected:
version_count = 1
roll = RNG.random()
if roll < 0.08 and num_qn >= 2:
version_count = 4
elif roll < 0.20:
version_count = 3
elif roll < 0.38:
version_count = 2
uploads = []
for v in range(version_count):
if v > 0:
# Re-upload after days or weeks (sometimes same day correction).
if RNG.random() < 0.18:
gap_sec = RNG.randint(2 * 3600, 36 * 3600)
else:
gap_sec = RNG.randint(2 * 86400, 55 * 86400)
cursor += gap_sec
else:
# First upload for this qn: often a few days after previous qn.
cursor += RNG.randint(0, 12 * 86400)
duration = RNG.randint(4 * 60, 55 * 60)
started = cursor - duration
completed = cursor + RNG.randint(0, 120)
uploads.append({
"submittedAt": completed,
"startedAt": started,
"variant": variant_seed(client_code, qn_id, v),
})
completions.append({
"clientCode": client_code,
"questionnaireID": qn_id,
"versions": version_count,
"uploads": uploads,
})
RNG.shuffle(completions)
return completions
def main():
bundle_path = resolve_bundle_path()
questionnaire_ids = load_questionnaire_ids(bundle_path)
admins = [
{"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"}
for i in range(1, NUM_ADMINS + 1)
]
supervisors = [
{"username": f"{PREFIX}supervisor_{i}", "location": f"Dev Supervisor Region {i}"}
for i in range(1, NUM_SUPERVISORS + 1)
]
coaches = []
for i in range(1, NUM_COACHES + 1):
sv = (i - 1) % NUM_SUPERVISORS + 1
coaches.append({
"username": f"{PREFIX}coach_{i:02d}",
"supervisor": f"{PREFIX}supervisor_{sv}",
})
clients = [
{"clientCode": f"{CLIENT_PREFIX}{i:04d}"}
for i in range(1, NUM_CLIENTS + 1)
]
assign_coaches(clients, coaches)
all_codes = [c["clientCode"] for c in clients]
no_data_codes = set(RNG.sample(all_codes, min(CLIENTS_WITHOUT_COMPLETIONS, len(all_codes))))
clients_with_data = [c for c in all_codes if c not in no_data_codes]
completions = build_completions(questionnaire_ids, clients_with_data)
total_uploads = sum(c["versions"] for c in completions)
multi_version_pairs = sum(1 for c in completions if c["versions"] > 1)
bundle_note = bundle_path.name if bundle_path and bundle_path.is_file() else "fallback questionnaire list"
fixture = {
"fixtureVersion": 2,
"description": (
f"Dev/test users, clients, completions with uneven coach load and upload history "
f"({SCALE}x scale). Questionnaires from {bundle_note}. Password: socialvrlab."
),
"prefix": PREFIX,
"defaultPassword": PASSWORD,
"sourceBundle": bundle_note,
"questionnaireIDs": questionnaire_ids,
"admins": admins,
"supervisors": supervisors,
"coaches": coaches,
"clients": clients,
"completions": completions,
"stats": {
"scale": SCALE,
"admins": len(admins),
"supervisors": len(supervisors),
"coaches": len(coaches),
"clients": len(clients),
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
"completionRecords": len(completions),
"totalUploads": total_uploads,
"multiVersionPairs": multi_version_pairs,
"questionnaires": len(questionnaire_ids),
},
}
out = REPO_ROOT / "dev-test-fixture.json"
out.write_text(json.dumps(fixture, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {out}")
print(json.dumps(fixture["stats"], indent=2))
if __name__ == "__main__":
main()

66
seed_admin.php Normal file
View File

@ -0,0 +1,66 @@
<?php
// CLI tool: php seed_admin.php <username> <password> [location]
// Creates an initial admin user in the encrypted database.
// If the DB doesn't exist yet, it is created from schema.sql.
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo "CLI only\n";
exit(1);
}
require_once __DIR__ . '/db_init.php';
$usage = "Usage: php seed_admin.php <username> <password> [location]\n";
$username = $argv[1] ?? '';
$password = $argv[2] ?? '';
$location = $argv[3] ?? '';
if ($username === '' || $password === '') {
fwrite(STDERR, $usage);
exit(1);
}
if (strlen($password) < 6) {
fwrite(STDERR, "Error: Password must be at least 6 characters.\n");
exit(1);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$existing = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$existing->execute([':u' => $username]);
if ($existing->fetch()) {
qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: User '$username' already exists.\n");
exit(1);
}
$adminID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $adminID, ':u' => $username, ':loc' => $location]);
$pdo->prepare(
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :hash, 'admin', :eid, 1, :now)"
)->execute([':uid' => $userID, ':u' => $username, ':hash' => $hash, ':eid' => $adminID, ':now' => $now]);
$pdo->commit();
$pdo = null;
qdb_save($tmpDb, $lockFp);
echo "Admin user '$username' created successfully (must change password on first login).\n";
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
exit(1);
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\QdbTestCase;
final class ApiRoutingTest extends QdbTestCase
{
public function testUnknownRoute404(): void
{
$res = $this->api()->request('GET', 'does-not-exist');
$this->assertApiError($res, 'NOT_FOUND', 404);
}
public function testUnauthorizedWithoutToken(): void
{
$res = $this->api()->request('GET', 'questionnaires');
$this->assertApiError($res, 'UNAUTHORIZED', 401);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppQuestionnairesBulkTest extends QdbTestCase
{
public function testCoachLoadsBulkAnswers(): void
{
$this->submitFixtureQuestionnaire();
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
'answersBulk' => '1',
]);
$this->assertApiOk($res);
$this->assertTrue($res['data']['encrypted'] ?? false);
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
$this->assertIsArray($payload['clients'] ?? null);
$found = null;
foreach ($payload['clients'] as $row) {
if (($row['clientCode'] ?? '') === $this->fixture()->clientCode) {
$found = $row;
break;
}
}
$this->assertNotNull($found, 'fixture client missing from bulk payload');
$this->assertNotEmpty($found['questionnaires'] ?? []);
$qn = $found['questionnaires'][0];
$this->assertSame($this->fixture()->questionnaireId, $qn['questionnaireID'] ?? null);
$this->assertNotEmpty($qn['answers'] ?? []);
}
public function testReadDbOpenUsesCacheOnSecondCall(): void
{
[$pdo1, $tmp1] = qdb_open(false);
[$pdo2, $tmp2] = qdb_open(false);
$this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp1);
$this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp2);
$this->assertSame($pdo1, $pdo2);
qdb_discard($tmp1, null);
qdb_discard($tmp2, null);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppQuestionnairesTest extends QdbTestCase
{
public function testCoachSubmitsQuestionnaire(): void
{
$res = $this->submitFixtureQuestionnaire();
$this->assertApiOk($res);
$this->assertTrue($res['data']['submitted'] ?? false);
}
public function testCoachLoadsQuestionnaireDefinition(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
'id' => $this->fixture()->questionnaireId,
]);
$this->assertApiOk($res);
$this->assertNotEmpty($res['data']['questions']);
$glass = null;
foreach ($res['data']['questions'] as $q) {
if (($q['layout'] ?? '') === 'glass_scale_question') {
$glass = $q;
break;
}
}
$this->assertNotNull($glass);
$this->assertIsArray($glass['glassSymptoms'] ?? null);
$symptomKeys = array_column($glass['glassSymptoms'], 'key');
$this->assertContains($this->fixture()->glassSymptomKey, $symptomKeys);
}
public function testCoachListsAssignedClients(): void
{
$this->submitFixtureQuestionnaire();
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
'clients' => '1',
]);
$this->assertApiOk($res);
$this->assertTrue($res['data']['encrypted'] ?? false);
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
$codes = array_column($payload['clients'], 'clientCode');
$this->assertContains($this->fixture()->clientCode, $codes);
}
}

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppSubmitExtendedTest extends QdbTestCase
{
public function testResubmitClearsOmittedOptionalAnswers(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Old notes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
]));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$ft = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]);
$this->assertFalse($ft->fetchColumn());
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$this->assertFalse($glass->fetchColumn());
$sub = $pdo->prepare(
'SELECT submissionID FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$submissionId = (string)$sub->fetchColumn();
$archived = $pdo->prepare(
'SELECT freeTextValue FROM client_answer_submission
WHERE submissionID = :sid AND questionID = :qid'
);
$archived->execute([':sid' => $submissionId, ':qid' => $f->freeTextQuestionId]);
$this->assertFalse($archived->fetchColumn());
$archivedGlass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer_submission
WHERE submissionID = :sid AND questionID = :qid'
);
$archivedGlass->execute([':sid' => $submissionId, ':qid' => $f->glassQuestionId]);
$this->assertFalse($archivedGlass->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
public function testResubmitReplacesGlassSymptomsInsteadOfMerging(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
]));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'mild'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$decoded = json_decode((string)$glass->fetchColumn(), true);
$this->assertSame(['pain' => 'mild'], $decoded);
qdb_discard($tmpDb, $lockFp);
}
public function testResubmitCreatesSecondSubmissionVersion(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitFixtureQuestionnaire());
$this->assertSame(1, $this->submissionVersionCount($f->clientCode, $f->questionnaireId));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$this->assertApiOk($res);
$this->assertTrue($res['data']['submitted'] ?? false);
$this->assertSame(2, $this->submissionVersionCount($f->clientCode, $f->questionnaireId));
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM client_answer_submission cas
INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID
WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn'
);
$stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$this->assertGreaterThanOrEqual(2, (int)$stmt->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
public function testSupervisorCanSubmitForClient(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare(
'SELECT submittedByRole FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$this->assertSame('supervisor', $stmt->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
public function testSubmitWithFreeTextAndGlassSymptom(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Feeling better'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$ft = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]);
$this->assertSame('Feeling better', $ft->fetchColumn());
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$json = (string)$glass->fetchColumn();
$decoded = json_decode($json, true);
$this->assertIsArray($decoded);
$this->assertSame('moderate', $decoded[$f->glassSymptomKey] ?? null);
qdb_discard($tmpDb, $lockFp);
}
public function testCoachLoadsEncryptedAnswersBundle(): void
{
$this->submitFixtureQuestionnaire();
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
'clientCode' => $f->clientCode,
'answers' => '1',
]);
$this->assertApiOk($res);
$this->assertTrue($res['data']['encrypted'] ?? false);
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame($f->clientCode, $payload['clientCode']);
$this->assertNotEmpty($payload['questionnaires']);
}
public function testMultiCheckSubmitAccumulatesPoints(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_a'],
['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_b'],
]);
$this->assertApiOk($res);
$this->assertSame(3, $res['data']['sumPoints'] ?? 0);
}
public function testRequiredQuestionMissingFailsValidation(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'skipped consent'],
]);
$this->assertApiError($res, 'VALIDATION_FAILED', 400);
$codes = array_column($res['error']['details']['errors'] ?? [], 'code');
$this->assertContains('MISSING_ANSWER', $codes);
}
public function testActiveListExcludesDraftQuestionnaire(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires');
$this->assertApiOk($res);
$ids = array_column($res['data'], 'id');
$this->assertContains($f->questionnaireId, $ids);
$this->assertNotContains($f->draftQuestionnaireId, $ids);
}
public function testTempTokenCannotSubmitQuestionnaire(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
$pdo->prepare('UPDATE users SET mustChangePassword = 1 WHERE userID = :uid')
->execute([':uid' => $f->supervisorUserId]);
qdb_save($tmpDb, $lockFp);
$login = $this->api()->loginMobile(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
);
$this->assertApiOk($login);
$tempToken = $login['data']['token'];
$res = $this->api()->encryptedPost($tempToken, 'app_questionnaires', [
'questionnaireID' => $f->questionnaireId,
'clientCode' => $f->clientCode,
'answers' => [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
],
]);
$this->assertApiError($res, 'PASSWORD_CHANGE_REQUIRED', 403);
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppSubmitLegacyTest extends QdbTestCase
{
public function testLegacySubmitAcceptsRetiredQuestionAfterBump(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
null,
null,
1
));
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'legacy_test_retire');
qdb_retire_question($pdo, $f->questionId, $rev);
$currentRev = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
qdb_save($tmpDb, $lockFp);
$this->assertGreaterThan(1, $currentRev);
$res = $this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
null,
null,
1
);
$this->assertApiOk($res);
$this->assertTrue($res['data']['submitted'] ?? false);
$this->assertTrue($res['data']['legacySubmit'] ?? false);
$this->assertSame(1, $res['data']['structureRevision'] ?? null);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$sub = $pdo->prepare(
'SELECT structureRevision, structureSnapshotJson FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$row = $sub->fetch(\PDO::FETCH_ASSOC);
$this->assertSame(1, (int)($row['structureRevision'] ?? 0));
$snap = json_decode($row['structureSnapshotJson'] ?? '{}', true) ?: [];
$this->assertNotEmpty($snap['questions'] ?? []);
$archived = $pdo->prepare(
'SELECT COUNT(*) FROM client_answer_submission cas
INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID
WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn AND cas.questionID = :qid'
);
$archived->execute([
':cc' => $f->clientCode,
':qn' => $f->questionnaireId,
':qid' => $f->questionId,
]);
$this->assertGreaterThan(0, (int)$archived->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AssignmentsActivityLogTest extends QdbTestCase
{
public function testAssignmentsListForAdmin(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'assignments');
$this->assertApiOk($res);
$this->assertArrayHasKey('coaches', $res['data']);
$this->assertArrayHasKey('clients', $res['data']);
}
public function testActivityLogReadableByAdmin(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$today = gmdate('Y-m-d');
$res = $this->api()->withToken($token, 'GET', 'activity-log', null, [
'date' => $today,
'limit' => '50',
]);
$this->assertApiOk($res);
$this->assertArrayHasKey('entries', $res['data']);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AuthTest extends QdbTestCase
{
public function testAdminLoginSuccess(): void
{
$res = $this->api()->loginWeb(DatabaseSeeder::ADMIN_USERNAME, DatabaseSeeder::PASSWORD);
$this->assertApiOk($res);
$this->assertNotEmpty($res['data']['token']);
$this->assertSame('admin', $res['data']['role']);
}
public function testInvalidCredentials(): void
{
$res = $this->api()->loginWeb(DatabaseSeeder::ADMIN_USERNAME, 'wrong');
$this->assertApiError($res, 'INVALID_CREDENTIALS', 401);
}
public function testCoachBlockedOnWebClient(): void
{
$res = $this->api()->loginWeb(DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD);
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testRateLimitAfterFailures(): void
{
require_once dirname(__DIR__, 2) . '/lib/settings.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
qdb_settings_save_on_pdo($pdo, qdb_settings_validate_and_merge([
'login_max_attempts' => 2,
'login_window_seconds' => 600,
'login_lockout_seconds' => 600,
], qdb_settings_get_on_pdo($pdo)));
qdb_save($tmpDb, $lockFp);
$this->api()->loginWeb('rate_user', 'bad1');
$this->api()->loginWeb('rate_user', 'bad2');
$res = $this->api()->loginWeb('rate_user', 'bad3');
$this->assertApiError($res, 'RATE_LIMITED', 429);
}
public function testMissingFields(): void
{
$res = $this->api()->request('POST', 'auth/login', ['username' => '']);
$this->assertApiError($res, 'MISSING_FIELDS', 400);
}
public function testKeycloakConfigDisabledByDefault(): void
{
$res = $this->api()->request('GET', 'auth/keycloak-config');
$this->assertApiOk($res);
$this->assertFalse($res['data']['enabled']);
$this->assertSame('', $res['data']['loginUrl']);
}
public function testKeycloakLoginRequiresConfiguration(): void
{
$res = $this->api()->request('GET', 'auth/keycloak-login');
$this->assertApiError($res, 'KEYCLOAK_NOT_CONFIGURED', 503);
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class BackupDevOpsTest extends QdbTestCase
{
public function testAdminBackupCreatesCopyOfDatabase(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'POST', 'backup');
$this->assertApiOk($res);
$filename = $res['data']['filename'] ?? '';
$this->assertNotSame('', $filename);
$path = QDB_UPLOADS_DIR . '/backups/' . $filename;
$this->assertFileExists($path);
$this->assertGreaterThan(0, filesize($path));
$this->assertSame(filesize(QDB_PATH), filesize($path));
}
public function testDevImportEmptyFixtureSucceeds(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'POST', 'dev', [
'fixture' => [
'fixtureVersion' => 2,
'prefix' => 'dev_',
'defaultPassword' => 'DevPass1!',
'admins' => [],
'supervisors' => [],
'coaches' => [],
'clients' => [],
],
]);
$this->assertApiOk($res);
$this->assertSame(0, $res['data']['imported']['clients'] ?? -1);
}
public function testDevRejectsInvalidFixtureVersion(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'POST', 'dev', [
'fixture' => [
'fixtureVersion' => 99,
'prefix' => 'dev_',
'defaultPassword' => 'DevPass1!',
],
]);
$this->assertApiError($res, 'INVALID_FIELD', 400);
}
public function testMigrationAddsCategoryKeyColumn(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
$this->markTestSkipped('categoryKey already absent in fresh schema');
}
$pdo->exec('ALTER TABLE questionnaire DROP COLUMN categoryKey');
qdb_save($tmpDb, $lockFp);
[$pdo2, $tmp2, $lock2] = qdb_open(false);
$this->assertTrue(qdb_column_exists($pdo2, 'questionnaire', 'categoryKey'));
qdb_discard($tmp2, $lock2);
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class CatalogApiTest extends QdbTestCase
{
public function testListLanguages(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['languages' => '1']);
$this->assertApiOk($res);
$this->assertIsArray($res['data']['languages']);
}
public function testListQuestionsForQuestionnaire(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'GET', 'questions', null, [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertApiOk($res);
$ids = array_column($res['data']['questions'], 'questionID');
$this->assertContains($f->questionId, $ids);
}
public function testListAnswerOptions(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'answer_options', null, [
'questionID' => $this->fixture()->questionId,
]);
$this->assertApiOk($res);
$this->assertNotEmpty($res['data']['answerOptions']);
}
public function testListCoachesForSupervisor(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'coaches');
$this->assertApiOk($res);
$coachIds = array_column($res['data']['coaches'], 'coachID');
$this->assertContains($this->fixture()->coachId, $coachIds);
}
public function testTranslationsAllDashboard(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['all' => '1']);
$this->assertApiOk($res);
$this->assertArrayHasKey('questionnaires', $res['data']);
$this->assertArrayHasKey('entries', $res['data']);
}
public function testTranslationsForQuestionnaire(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'translations', null, [
'questionnaireID' => $this->fixture()->questionnaireId,
]);
$this->assertApiOk($res);
$this->assertArrayHasKey('entries', $res['data']);
}
public function testAppTranslationsPublicNoAuth(): void
{
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']);
$this->assertApiOk($res);
$this->assertArrayHasKey('translations', $res['data']);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\QdbTestCase;
final class ChangePasswordTest extends QdbTestCase
{
public function testMustChangePasswordFlow(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
$pdo->prepare('UPDATE users SET mustChangePassword = 1 WHERE userID = :uid')
->execute([':uid' => $f->supervisorUserId]);
qdb_save($tmpDb, $lockFp);
$login = $this->api()->loginWeb(
\Tests\Support\DatabaseSeeder::SUPERVISOR_USERNAME,
\Tests\Support\DatabaseSeeder::PASSWORD
);
$this->assertApiOk($login);
$this->assertTrue($login['data']['mustChangePassword']);
$tempToken = $login['data']['token'];
$change = $this->api()->request('POST', 'auth/change-password', [
'username' => 'test_supervisor',
'old_password' => 'TestPass1!',
'new_password' => 'NewPass2!',
], ['Authorization' => 'Bearer ' . $tempToken, 'X-QDB-Client' => 'web']);
$this->assertApiOk($change);
$this->assertNotEmpty($change['data']['token']);
$session = $this->api()->withToken($change['data']['token'], 'GET', 'session');
$this->assertApiOk($session);
$this->assertFalse($session['data']['mustChangePassword'] ?? false);
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class ClientsLifecycleTest extends QdbTestCase
{
public function testClientDetailAfterSubmit(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$code = $this->fixture()->clientCode;
$res = $this->api()->withToken($token, 'GET', 'clients', null, [
'clientCode' => $code,
'detail' => '1',
]);
$this->assertApiOk($res);
$this->assertSame($code, $res['data']['client']['clientCode'] ?? '');
$this->assertNotEmpty($res['data']['questionnaires']);
}
public function testSupervisorCreatesClientForOwnCoach(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$code = 'CLIENT-SV-' . bin2hex(random_bytes(2));
$res = $this->api()->withToken($token, 'POST', 'clients', [
'clientCode' => $code,
'coachID' => $this->fixture()->coachId,
]);
$this->assertApiOk($res);
$this->assertSame($code, $res['data']['clientCode']);
}
public function testArchiveHidesClientFromListsAndFollowUp(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$code = $this->fixture()->clientCode;
$archive = $this->api()->withToken($token, 'PATCH', 'clients', [
'clientCode' => $code,
'archived' => true,
]);
$this->assertApiOk($archive);
$this->assertSame(1, (int)($archive['data']['archived'] ?? 0));
$active = $this->api()->withToken($token, 'GET', 'clients');
$this->assertApiOk($active);
$activeCodes = array_column($active['data']['clients'], 'clientCode');
$this->assertNotContains($code, $activeCodes);
$archivedOnly = $this->api()->withToken($token, 'GET', 'clients', null, [
'archiveFilter' => 'archived',
]);
$this->assertApiOk($archivedOnly);
$archivedCodes = array_column($archivedOnly['data']['clients'], 'clientCode');
$this->assertContains($code, $archivedCodes);
$assign = $this->api()->withToken($token, 'GET', 'assignments');
$this->assertApiOk($assign);
$assignCodes = array_column($assign['data']['clients'], 'clientCode');
$this->assertNotContains($code, $assignCodes);
$stale = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
$this->assertApiOk($stale);
$staleCodes = array_column($stale['data']['clients'], 'clientCode');
$this->assertNotContains($code, $staleCodes);
$restore = $this->api()->withToken($token, 'PATCH', 'clients', [
'clientCode' => $code,
'archived' => false,
]);
$this->assertApiOk($restore);
$this->assertSame(0, (int)($restore['data']['archived'] ?? 1));
$activeAgain = $this->api()->withToken($token, 'GET', 'clients');
$this->assertApiOk($activeAgain);
$this->assertContains($code, array_column($activeAgain['data']['clients'], 'clientCode'));
}
public function testAdminDeletesClient(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$code = 'CLIENT-DEL-' . bin2hex(random_bytes(2));
$create = $this->api()->withToken($token, 'POST', 'clients', [
'clientCode' => $code,
'coachID' => $this->fixture()->coachId,
]);
$this->assertApiOk($create);
$del = $this->api()->withToken($token, 'DELETE', 'clients', ['clientCode' => $code]);
$this->assertApiOk($del);
$list = $this->api()->withToken($token, 'GET', 'clients');
$codes = array_column($list['data']['clients'], 'clientCode');
$this->assertNotContains($code, $codes);
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class ClientsQuestionnairesTest extends QdbTestCase
{
public function testSupervisorSeesClients(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'clients');
$this->assertApiOk($res);
$codes = array_column($res['data']['clients'], 'clientCode');
$this->assertContains($this->fixture()->clientCode, $codes);
}
public function testAdminCreatesClient(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$code = 'CLIENT-NEW-' . bin2hex(random_bytes(2));
$res = $this->api()->withToken($token, 'POST', 'clients', [
'clientCode' => $code,
'coachID' => $this->fixture()->coachId,
]);
$this->assertApiOk($res);
$this->assertSame($code, $res['data']['clientCode']);
}
public function testQuestionnairesList(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'questionnaires');
$this->assertApiOk($res);
$ids = array_column($res['data']['questionnaires'], 'questionnaireID');
$this->assertContains($this->fixture()->questionnaireId, $ids);
}
public function testQuestionsRequireQuestionnaireId(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'questions');
$this->assertApiError($res, 'BAD_REQUEST', 400);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class CoachesActivityTest extends QdbTestCase
{
public function testCoachRecentSubmissions(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'coaches', null, [
'coachID' => $this->fixture()->coachId,
'recent' => '1',
'limit' => '10',
]);
$this->assertApiOk($res);
$this->assertArrayHasKey('submissions', $res['data']);
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\QdbTestCase;
final class DbInitTest extends QdbTestCase
{
public function testEncryptedDbRoundTrip(): void
{
$this->assertFileExists(QDB_PATH);
[$pdo1, $t1, $l1] = qdb_open(false);
$count1 = (int)$pdo1->query('SELECT COUNT(*) FROM users')->fetchColumn();
qdb_discard($t1, $l1);
[$pdo2, $t2, $l2] = qdb_open(true);
$pdo2->exec("INSERT INTO language (languageCode, name) VALUES ('fr', 'French')
ON CONFLICT(languageCode) DO NOTHING");
qdb_save($t2, $l2);
[$pdo3, $t3, $l3] = qdb_open(false);
$fr = $pdo3->query("SELECT name FROM language WHERE languageCode = 'fr'")->fetchColumn();
qdb_discard($t3, $l3);
$this->assertSame('French', $fr);
$this->assertGreaterThan(0, $count1);
}
public function testSystemSettingTableExists(): void
{
[$pdo, $tmp, $lock] = qdb_open(false);
$this->assertTrue(qdb_table_exists($pdo, 'system_setting'));
$this->assertTrue(qdb_table_exists($pdo, 'session'));
qdb_discard($tmp, $lock);
}
public function testMigrationsRecreateDroppedSubmissionTables(): void
{
[$pdo, $tmp, $lock] = qdb_open(true);
$pdo->exec('DROP TABLE IF EXISTS client_answer_submission');
$pdo->exec('DROP TABLE IF EXISTS questionnaire_submission');
qdb_save($tmp, $lock);
[$pdo2, $tmp2, $lock2] = qdb_open(false);
$this->assertTrue(qdb_table_exists($pdo2, 'questionnaire_submission'));
$this->assertTrue(qdb_table_exists($pdo2, 'client_answer_submission'));
qdb_discard($tmp2, $lock2);
$this->assertFileExists(QDB_PATH);
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class DownloadsTest extends QdbTestCase
{
public function testExportTranslationsBundle(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['exportBundle' => '1']);
$this->assertRawOk($res, 'application/json');
$decoded = json_decode($res['body'], true);
$this->assertIsArray($decoded);
}
public function testExportQuestionnairesBundle(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export', null, ['bundle' => '1']);
$this->assertRawOk($res, 'application/json');
$decoded = json_decode($res['body'], true);
$this->assertIsArray($decoded);
}
public function testExportAllZipAsAdmin(): void
{
if (!class_exists('ZipArchive')) {
$this->markTestSkipped('php-zip extension not installed');
}
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export', null, ['exportAll' => '1']);
$this->assertRawOk($res, 'application/zip');
$this->assertStringStartsWith('PK', $res['body']);
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class EditorCrudTest extends QdbTestCase
{
public function testAdminUpdatesAndDeletesAnswerOption(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$create = $this->api()->withToken($token, 'POST', 'answer_options', [
'questionID' => $f->questionId,
'optionKey' => 'temp_opt',
'defaultText' => 'Temporary',
'points' => 1,
]);
$this->assertApiOk($create);
$optId = $create['data']['answerOption']['answerOptionID'] ?? '';
$this->assertNotSame('', $optId);
$update = $this->api()->withToken($token, 'PUT', 'answer_options', [
'answerOptionID' => $optId,
'optionKey' => 'temp_opt',
'defaultText' => 'Temporary updated',
'points' => 5,
]);
$this->assertApiOk($update);
$this->assertSame(5, $update['data']['answerOption']['points'] ?? 0);
$del = $this->api()->withToken($token, 'DELETE', 'answer_options', [
'answerOptionID' => $optId,
]);
$this->assertApiOk($del);
$list = $this->api()->withToken($token, 'GET', 'answer_options', null, [
'questionID' => $f->questionId,
]);
$this->assertApiOk($list);
$ids = array_column($list['data']['answerOptions'], 'answerOptionID');
$this->assertNotContains($optId, $ids);
}
public function testAdminUpdatesAndDeletesQuestion(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$create = $this->api()->withToken($token, 'POST', 'questions', [
'questionnaireID' => $f->questionnaireId,
'questionKey' => 'disposable_q',
'defaultText' => 'Disposable',
'type' => 'free_text_question',
'isRequired' => 0,
'configJson' => '{}',
]);
$this->assertApiOk($create);
$qid = $create['data']['question']['questionID'] ?? '';
$this->assertNotSame('', $qid);
$update = $this->api()->withToken($token, 'PUT', 'questions', [
'questionID' => $qid,
'defaultText' => 'Disposable updated',
'questionKey' => 'disposable_q',
]);
$this->assertApiOk($update);
$this->assertSame('Disposable updated', $update['data']['question']['defaultText'] ?? '');
$del = $this->api()->withToken($token, 'DELETE', 'questions', [
'questionID' => $qid,
]);
$this->assertApiOk($del);
$list = $this->api()->withToken($token, 'GET', 'questions', null, [
'questionnaireID' => $f->questionnaireId,
]);
$ids = array_column($list['data']['questions'], 'questionID');
$this->assertNotContains($qid, $ids);
}
public function testAdminReordersQuestions(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$before = $this->api()->withToken($token, 'GET', 'questions', null, [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertApiOk($before);
$ids = array_column($before['data']['questions'], 'questionID');
$reversed = array_reverse($ids);
$patch = $this->api()->withToken($token, 'PATCH', 'questions', [
'questionnaireID' => $f->questionnaireId,
'order' => $reversed,
]);
$this->assertApiOk($patch);
$after = $this->api()->withToken($token, 'GET', 'questions', null, [
'questionnaireID' => $f->questionnaireId,
]);
$afterIds = array_column($after['data']['questions'], 'questionID');
$this->assertSame($reversed, $afterIds);
}
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
use ZipArchive;
final class ExportExtendedTest extends QdbTestCase
{
public function testExportAllVersionsCsvIncludesBothSubmissions(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitFixtureQuestionnaire());
$this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export', null, [
'questionnaireID' => $f->questionnaireId,
'allVersions' => '1',
]);
$this->assertRawOk($res, 'text/csv');
$this->assertStringContainsString($f->clientCode, $res['body']);
$lines = array_filter(explode("\n", trim($res['body'])));
$this->assertGreaterThanOrEqual(3, count($lines), 'header plus at least two data rows');
}
public function testExportAllZipContainsCsvForSubmittedQuestionnaire(): void
{
if (!class_exists(ZipArchive::class)) {
$this->markTestSkipped('php-zip extension not installed');
}
$this->submitFixtureQuestionnaire();
$f = $this->fixture();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export', null, ['exportAll' => '1']);
$this->assertRawOk($res, 'application/zip');
$zipPath = sys_get_temp_dir() . '/qdb_test_export_' . bin2hex(random_bytes(4)) . '.zip';
file_put_contents($zipPath, $res['body']);
$zip = new ZipArchive();
$this->assertTrue($zip->open($zipPath) === true);
$this->assertGreaterThanOrEqual(1, $zip->numFiles);
$foundCsv = false;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (is_string($name) && str_ends_with(strtolower($name), '.csv')) {
$csv = $zip->getFromIndex($i);
if (is_string($csv) && str_contains($csv, $f->clientCode)) {
$foundCsv = true;
break;
}
}
}
$zip->close();
@unlink($zipPath);
$this->assertTrue($foundCsv, 'ZIP should contain a CSV with submitted client code');
}
public function testCoachForbiddenOnPerQuestionnaireCsvExport(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withMobileToken($token, 'GET', 'export', null, [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testExportAllWithAllVersionsZip(): void
{
if (!class_exists(\ZipArchive::class)) {
$this->markTestSkipped('php-zip extension not installed');
}
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->submitFixtureQuestionnaire();
$this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export', null, [
'exportAll' => '1',
'allVersions' => '1',
]);
$this->assertRawOk($res, 'application/zip');
$this->assertStringStartsWith('PK', $res['body']);
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class ExportTest extends QdbTestCase
{
public function testExportRequiresQuestionnaireId(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export');
$this->assertApiError($res, 'MISSING_PARAM', 400);
}
public function testExportAllZipRequiresAdminRole(): void
{
if (!class_exists('ZipArchive')) {
$this->markTestSkipped('php-zip extension not installed');
}
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'export', null, ['exportAll' => '1']);
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testExportCsvForQuestionnaire(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'GET', 'export', null, [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertRawOk($res, 'text/csv');
$this->assertStringContainsString($f->clientCode, $res['body']);
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class QuestionnaireEditingTest extends QdbTestCase
{
public function testAdminCreatesQuestion(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'POST', 'questions', [
'questionnaireID' => $f->questionnaireId,
'questionKey' => 'extra_q',
'defaultText' => 'Extra question text',
'type' => 'free_text',
'isRequired' => 0,
'configJson' => '{}',
]);
$this->assertApiOk($res);
$this->assertSame('extra_q', $res['data']['question']['questionKey'] ?? '');
}
public function testAdminCreatesAnswerOption(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'POST', 'answer_options', [
'questionID' => $f->questionId,
'optionKey' => 'no_option',
'defaultText' => 'No',
'points' => 0,
]);
$this->assertApiOk($res);
$this->assertSame('no_option', $res['data']['answerOption']['optionKey'] ?? '');
}
public function testAnswerOptionsRequireQuestionId(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'answer_options');
$this->assertApiError($res, 'MISSING_PARAM', 400);
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class QuestionnaireImportExportTest extends QdbTestCase
{
public function testExportImportRoundTripPreservesQuestionnaire(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$export = $this->api()->withToken($token, 'GET', 'export', null, ['bundle' => '1']);
$this->assertRawOk($export, 'application/json');
$bundle = json_decode($export['body'], true, 512, JSON_THROW_ON_ERROR);
$this->assertGreaterThanOrEqual(1, $bundle['questionnaireCount'] ?? 0);
$del = $this->api()->withToken($token, 'DELETE', 'questionnaires', [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertApiOk($del);
$list = $this->api()->withToken($token, 'GET', 'questionnaires');
$ids = array_column($list['data']['questionnaires'], 'questionnaireID');
$this->assertNotContains($f->questionnaireId, $ids);
$import = $this->api()->withToken($token, 'POST', 'questionnaires', [
'action' => 'importBundle',
'bundle' => $bundle,
'replaceIfExists' => true,
]);
$this->assertApiOk($import);
$this->assertGreaterThanOrEqual(1, $import['data']['imported'] ?? 0);
$questions = $this->api()->withToken($token, 'GET', 'questions', null, [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertApiOk($questions);
$qIds = array_column($questions['data']['questions'], 'questionID');
$this->assertContains($f->questionId, $qIds);
$this->assertContains($f->freeTextQuestionId, $qIds);
}
public function testAdminCreatesAndUpdatesQuestionnaire(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$create = $this->api()->withToken($token, 'POST', 'questionnaires', [
'name' => 'Imported via API',
'version' => '2',
'state' => 'draft',
]);
$this->assertApiOk($create);
$newId = $create['data']['questionnaire']['questionnaireID'] ?? '';
$this->assertNotSame('', $newId);
$update = $this->api()->withToken($token, 'PUT', 'questionnaires', [
'questionnaireID' => $newId,
'name' => 'Renamed questionnaire',
'state' => 'active',
]);
$this->assertApiOk($update);
$this->assertSame('Renamed questionnaire', $update['data']['questionnaire']['name'] ?? '');
$this->assertSame('active', $update['data']['questionnaire']['state'] ?? '');
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class RbacIntegrationTest extends QdbTestCase
{
public function testSupervisorCannotCreateSupervisorUser(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'POST', 'users', [
'username' => 'new_supervisor',
'password' => 'Secret1!',
'role' => 'supervisor',
'location' => 'Elsewhere',
]);
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testSupervisorCannotAssignClientToForeignCoach(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$foreignSupervisorId = 'sv-foreign-' . bin2hex(random_bytes(4));
$foreignCoachId = 'coach-foreign-' . bin2hex(random_bytes(4));
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $foreignSupervisorId, ':u' => 'foreign_sv', ':loc' => 'Region B']);
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
->execute([':id' => $foreignCoachId, ':sid' => $foreignSupervisorId, ':u' => 'foreign_coach']);
qdb_save($tmpDb, $lockFp);
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'POST', 'clients', [
'clientCode' => 'CLIENT-FOREIGN-001',
'coachID' => $foreignCoachId,
]);
$this->assertApiError($res, 'NOT_FOUND', 404);
}
public function testCoachMobileTokenRejectedOnWebSettings(): void
{
$token = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'settings');
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testCoachCannotCreateAnswerOptionOnWeb(): void
{
$token = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'POST', 'answer_options', [
'questionID' => $this->fixture()->questionId,
'optionKey' => 'blocked',
'defaultText' => 'Blocked',
]);
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testSupervisorCannotReassignCoach(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'PUT', 'users', [
'userID' => $this->fixture()->coachUserId,
'supervisorID' => $this->fixture()->supervisorId,
]);
$this->assertApiError($res, 'FORBIDDEN', 403);
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class ResultsAnalyticsTest extends QdbTestCase
{
public function testResultsIncludeSubmittedClient(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'GET', 'results', null, [
'questionnaireID' => $f->questionnaireId,
]);
$this->assertApiOk($res);
$byCode = [];
foreach ($res['data']['clients'] as $row) {
$byCode[$row['clientCode']] = $row;
}
$this->assertArrayHasKey($f->clientCode, $byCode);
$this->assertSame('completed', $byCode[$f->clientCode]['status']);
$this->assertNotNull($byCode[$f->clientCode]['completedAt']);
}
public function testAnalyticsOverviewForSupervisor(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'analytics', null, ['overview' => '1']);
$this->assertApiOk($res);
$this->assertArrayHasKey('clientCount', $res['data']);
$this->assertArrayHasKey('questionnaires', $res['data']);
$this->assertGreaterThan(0, $res['data']['clientCount']);
}
public function testStaleClientsList(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
$this->assertApiOk($res);
$this->assertArrayHasKey('clients', $res['data']);
}
public function testFollowUpNoteRoundTrip(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->adminToken();
$code = $this->fixture()->clientCode;
$note = 'Call back next week';
$save = $this->api()->withToken($token, 'PUT', 'analytics', [
'clientCode' => $code,
'note' => $note,
]);
$this->assertApiOk($save);
$this->assertSame($note, $save['data']['note']);
$stale = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
$this->assertApiOk($stale);
$match = null;
foreach ($stale['data']['clients'] as $row) {
if (($row['clientCode'] ?? '') === $code) {
$match = $row;
break;
}
}
$this->assertIsArray($match);
$this->assertSame($note, $match['note']);
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class SecurityPathsTest extends QdbTestCase
{
public function testExpiredTokenRejected(): void
{
$token = bin2hex(random_bytes(32));
$f = $this->fixture();
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$pdo->prepare(
'INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
VALUES (:t, :uid, :role, :eid, :ca, :ea, 0)'
)->execute([
':t' => token_storage_key($token),
':uid' => $f->adminUserId,
':role' => 'admin',
':eid' => 'ent',
':ca' => time() - 7200,
':ea' => time() - 3600,
]);
qdb_save($tmpDb, $lockFp);
$res = $this->api()->withToken($token, 'GET', 'session');
$this->assertApiError($res, 'UNAUTHORIZED', 401);
}
public function testInvalidBearerRejected(): void
{
$res = $this->api()->request('GET', 'session', null, [
'Authorization' => 'Bearer not-a-valid-session-token',
'X-QDB-Client' => 'web',
]);
$this->assertApiError($res, 'UNAUTHORIZED', 401);
}
public function testAppSubmitRequiresEncryptedBody(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->api()->withMobileToken($login['token'], 'POST', 'app_questionnaires', [
'questionnaireID' => $f->questionnaireId,
'clientCode' => $f->clientCode,
'answers' => [],
]);
$this->assertApiError($res, 'ENCRYPTION_REQUIRED', 400);
}
public function testAppSubmitValidationFailure(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [
'questionnaireID' => $f->questionnaireId,
'clientCode' => $f->clientCode,
'answers' => [],
]);
$this->assertApiError($res, 'VALIDATION_FAILED', 400);
$this->assertNotEmpty($res['error']['details']['errors'] ?? []);
}
public function testCoachCannotSubmitForUnknownClient(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [
'questionnaireID' => $f->questionnaireId,
'clientCode' => 'CLIENT-DOES-NOT-EXIST',
'answers' => [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
],
]);
$this->assertApiError($res, 'NOT_FOUND', 404);
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class SessionLogoutTest extends QdbTestCase
{
public function testSessionValidWithToken(): void
{
$login = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withToken($login['token'], 'GET', 'session');
$this->assertApiOk($res);
$this->assertTrue($res['data']['valid']);
}
public function testSessionRejectsMissingToken(): void
{
$res = $this->api()->request('GET', 'session');
$this->assertApiError($res, 'UNAUTHORIZED', 401);
}
public function testLogoutRevokesToken(): void
{
$login = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
);
$del = $this->api()->withToken($login['token'], 'DELETE', 'logout');
$this->assertApiOk($del);
$check = $this->api()->withToken($login['token'], 'GET', 'session');
$this->assertApiError($check, 'UNAUTHORIZED', 401);
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class SettingsApiTest extends QdbTestCase
{
public function testAdminCanReadSettings(): void
{
$res = $this->api()->withToken($this->adminToken(), 'GET', 'settings');
$this->assertApiOk($res);
$this->assertArrayHasKey('settings', $res['data']);
$this->assertArrayHasKey('activeSessions', $res['data']);
}
public function testSupervisorForbidden(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'settings');
$this->assertApiError($res, 'FORBIDDEN', 403);
}
public function testUpdateSettings(): void
{
$token = $this->adminToken();
$res = $this->api()->withToken($token, 'PUT', 'settings', [
'login_max_attempts' => 8,
'login_window_seconds' => 1200,
'login_lockout_seconds' => 1200,
'session_ttl_seconds' => 86400,
'temp_session_ttl_seconds' => 900,
]);
$this->assertApiOk($res);
$this->assertSame(8, $res['data']['settings']['login_max_attempts']);
}
public function testRevokeAllSessionsRequiresPhrase(): void
{
$res = $this->api()->withToken($this->adminToken(), 'POST', 'settings', [
'action' => 'revokeAllSessions',
'confirmPhrase' => 'wrong',
]);
$this->assertApiError($res, 'CONFIRMATION_REQUIRED', 400);
}
public function testRevokeAllSessions(): void
{
$admin = $this->adminToken();
$this->api()->loginWebAndGetToken(DatabaseSeeder::SUPERVISOR_USERNAME, DatabaseSeeder::PASSWORD);
$res = $this->api()->withToken($admin, 'POST', 'settings', [
'action' => 'revokeAllSessions',
'confirmPhrase' => 'REVOKE ALL SESSIONS',
]);
$this->assertApiOk($res);
$this->assertGreaterThanOrEqual(1, $res['data']['revokedSessions']);
$check = $this->api()->withToken($admin, 'GET', 'session');
$this->assertApiError($check, 'UNAUTHORIZED', 401);
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\QdbTestCase;
final class TokenTest extends QdbTestCase
{
public function testTokenAddGetRevoke(): void
{
$token = bin2hex(random_bytes(32));
$f = $this->fixture();
token_add($token, 3600, [
'userID' => $f->adminUserId,
'role' => 'admin',
'entityID' => 'ent',
]);
$rec = token_get_record($token);
$this->assertNotNull($rec);
$this->assertSame('admin', $rec['role']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stored = $pdo->query('SELECT token FROM session LIMIT 1')->fetchColumn();
qdb_discard($tmpDb, $lockFp);
$this->assertSame(token_storage_key($token), $stored);
$this->assertNotSame($token, $stored);
token_revoke($token);
$this->assertNull(token_get_record($token));
}
public function testRevokeAllForUser(): void
{
$f = $this->fixture();
$t1 = bin2hex(random_bytes(16));
$t2 = bin2hex(random_bytes(16));
token_add($t1, 3600, ['userID' => $f->supervisorUserId, 'role' => 'supervisor', 'entityID' => 'x']);
token_add($t2, 3600, ['userID' => $f->supervisorUserId, 'role' => 'supervisor', 'entityID' => 'x']);
$n = token_revoke_all_for_user($f->supervisorUserId);
$this->assertGreaterThanOrEqual(2, $n);
$this->assertNull(token_get_record($t1));
}
public function testTokenLookupRefreshesExpiry(): void
{
$token = bin2hex(random_bytes(32));
$f = $this->fixture();
token_add($token, 3600, [
'userID' => $f->adminUserId,
'role' => 'admin',
'entityID' => 'ent',
]);
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$oldExpiry = time() + 60;
$pdo->prepare('UPDATE session SET expiresAt = :exp WHERE token = :t')
->execute([':exp' => $oldExpiry, ':t' => token_storage_key($token)]);
qdb_save($tmpDb, $lockFp);
$rec = token_get_record($token);
$this->assertNotNull($rec);
$this->assertGreaterThan($oldExpiry + 3000, $rec['exp']);
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class TranslationsImportExportTest extends QdbTestCase
{
public function testTranslationsBundleExportImportRoundTrip(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'language',
'languageCode' => 'es',
'name' => 'Spanish',
]);
$this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'question',
'id' => $f->questionId,
'languageCode' => 'es',
'text' => 'Consentimiento',
]);
$export = $this->api()->withToken($token, 'GET', 'translations', null, ['exportBundle' => '1']);
$this->assertRawOk($export, 'application/json');
$bundle = json_decode($export['body'], true, 512, JSON_THROW_ON_ERROR);
$this->api()->withToken($token, 'DELETE', 'translations', [
'type' => 'question',
'id' => $f->questionId,
'languageCode' => 'es',
]);
$import = $this->api()->withToken($token, 'POST', 'translations', [
'action' => 'importBundle',
'bundle' => $bundle,
]);
$this->assertApiOk($import);
$get = $this->api()->withToken($token, 'GET', 'translations', null, [
'type' => 'question',
'id' => $f->questionId,
]);
$es = null;
foreach ($get['data']['translations'] as $row) {
if ($row['languageCode'] === 'es') {
$es = $row['text'];
}
}
$this->assertSame('Consentimiento', $es);
}
public function testDeleteTranslationLanguage(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'language',
'languageCode' => 'it',
'name' => 'Italian',
]);
$del = $this->api()->withToken($token, 'DELETE', 'translations', [
'type' => 'language',
'languageCode' => 'it',
]);
$this->assertApiOk($del);
$langs = $this->api()->withToken($token, 'GET', 'translations', null, ['languages' => '1']);
$codes = array_column($langs['data']['languages'], 'languageCode');
$this->assertNotContains('it', $codes);
}
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class TranslationsWriteTest extends QdbTestCase
{
public function testPutQuestionTranslationAndReadBack(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$save = $this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'language',
'languageCode' => 'en',
'name' => 'English',
]);
$this->assertApiOk($save);
$put = $this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'question',
'id' => $f->questionId,
'languageCode' => 'en',
'text' => 'Consent in English?',
]);
$this->assertApiOk($put);
$get = $this->api()->withToken($token, 'GET', 'translations', null, [
'type' => 'question',
'id' => $f->questionId,
]);
$this->assertApiOk($get);
$texts = [];
foreach ($get['data']['translations'] as $row) {
$texts[$row['languageCode']] = $row['text'];
}
$this->assertArrayHasKey('en', $texts);
$this->assertSame('Consent in English?', $texts['en']);
}
public function testPutAnswerOptionTranslation(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'language',
'languageCode' => 'fr',
'name' => 'French',
]);
$put = $this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'answer_option',
'id' => $f->optionId,
'languageCode' => 'fr',
'text' => 'Oui',
]);
$this->assertApiOk($put);
$get = $this->api()->withToken($token, 'GET', 'translations', null, [
'type' => 'answer_option',
'id' => $f->optionId,
]);
$this->assertApiOk($get);
$fr = null;
foreach ($get['data']['translations'] as $row) {
if ($row['languageCode'] === 'fr') {
$fr = $row['text'];
}
}
$this->assertSame('Oui', $fr);
}
public function testAllTranslationsIncludeGlassSymptomKeys(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['all' => '1']);
$this->assertApiOk($res);
$stringKeys = [];
foreach ($res['data']['entries'] as $entry) {
if (($entry['type'] ?? '') === 'string') {
$stringKeys[] = $entry['key'] ?? '';
}
}
$this->assertContains($f->glassSymptomKey, $stringKeys);
$this->assertContains('fatigue', $stringKeys);
}
}

View File

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class UsersAdminTest extends QdbTestCase
{
public function testAdminCreatesCoach(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$username = 'coach_new_' . bin2hex(random_bytes(2));
$res = $this->api()->withToken($token, 'POST', 'users', [
'username' => $username,
'password' => 'CoachPass1!',
'role' => 'coach',
'supervisorID' => $f->supervisorId,
]);
$this->assertApiOk($res);
$this->assertSame($username, $res['data']['username']);
$this->assertSame('coach', $res['data']['role']);
$this->assertSame($f->supervisorId, $res['data']['supervisorID']);
$login = $this->api()->loginMobile($username, 'CoachPass1!');
$this->assertApiOk($login);
}
public function testSupervisorCreatesCoachUnderSelf(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$username = 'coach_sv_' . bin2hex(random_bytes(2));
$res = $this->api()->withToken($token, 'POST', 'users', [
'username' => $username,
'password' => 'CoachPass2!',
'role' => 'coach',
]);
$this->assertApiOk($res);
$this->assertSame($this->fixture()->supervisorId, $res['data']['supervisorID']);
}
public function testAdminResetsCoachPassword(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'PUT', 'users', [
'userID' => $f->coachUserId,
'newPassword' => 'ResetPass3!',
'mustChangePassword' => 0,
]);
$this->assertApiOk($res);
$oldLogin = $this->api()->loginMobile(DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD);
$this->assertApiError($oldLogin, 'INVALID_CREDENTIALS', 401);
$newLogin = $this->api()->loginMobile(DatabaseSeeder::COACH_USERNAME, 'ResetPass3!');
$this->assertApiOk($newLogin);
}
public function testAdminDeletesCreatedCoach(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$username = 'coach_del_' . bin2hex(random_bytes(2));
$create = $this->api()->withToken($token, 'POST', 'users', [
'username' => $username,
'password' => 'CoachPass4!',
'role' => 'coach',
'supervisorID' => $f->supervisorId,
]);
$this->assertApiOk($create);
$userId = $create['data']['userID'];
$del = $this->api()->withToken($token, 'DELETE', 'users', ['userID' => $userId]);
$this->assertApiOk($del);
$list = $this->api()->withToken($token, 'GET', 'users');
$ids = array_column($list['data']['users'], 'userID');
$this->assertNotContains($userId, $ids);
}
public function testAdminCreatesSupervisor(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$username = 'supervisor_new_' . bin2hex(random_bytes(2));
$res = $this->api()->withToken($token, 'POST', 'users', [
'username' => $username,
'password' => 'SuperPass1!',
'role' => 'supervisor',
'location' => 'Region B',
]);
$this->assertApiOk($res);
$this->assertSame('supervisor', $res['data']['role']);
}
public function testAdminReassignsCoachToSupervisor(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$sv = $this->api()->withToken($token, 'POST', 'users', [
'username' => 'supervisor_reassign_' . bin2hex(random_bytes(2)),
'password' => 'SuperPass2!',
'role' => 'supervisor',
'location' => 'Region C',
]);
$this->assertApiOk($sv);
$newSvId = $sv['data']['entityID'];
$res = $this->api()->withToken($token, 'PUT', 'users', [
'userID' => $f->coachUserId,
'supervisorID' => $newSvId,
]);
$this->assertApiOk($res);
$this->assertSame($newSvId, $res['data']['supervisorID']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare('SELECT supervisorID FROM coach WHERE coachID = :cid');
$stmt->execute([':cid' => $f->coachId]);
$this->assertSame($newSvId, $stmt->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
public function testAdminReassignsClientViaAssignments(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$newCoach = $this->api()->withToken($token, 'POST', 'users', [
'username' => 'coach_assign_' . bin2hex(random_bytes(2)),
'password' => 'CoachPass5!',
'role' => 'coach',
'supervisorID' => $f->supervisorId,
]);
$this->assertApiOk($newCoach);
$newCoachId = $newCoach['data']['entityID'];
$assign = $this->api()->withToken($token, 'POST', 'assignments', [
'coachID' => $newCoachId,
'clientCodes' => [$f->clientCode],
]);
$this->assertApiOk($assign);
$this->assertSame(1, $assign['data']['assigned']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare('SELECT coachID FROM client WHERE clientCode = :cc');
$stmt->execute([':cc' => $f->clientCode]);
$this->assertSame($newCoachId, $stmt->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class UsersTest extends QdbTestCase
{
public function testAdminListsUsers(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$res = $this->api()->withToken($token, 'GET', 'users');
$this->assertApiOk($res);
$this->assertGreaterThanOrEqual(3, count($res['data']['users']));
}
public function testRevokeUserSessions(): void
{
$admin = $this->adminToken();
$coach = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$res = $this->api()->withToken($admin, 'PUT', 'users', [
'action' => 'revokeSessions',
'userID' => $f->coachUserId,
]);
$this->assertApiOk($res);
$this->assertGreaterThanOrEqual(1, $res['data']['revokedSessions']);
$dead = $this->api()->withMobileToken($coach['token'], 'GET', 'session');
$this->assertApiError($dead, 'UNAUTHORIZED', 401);
}
public function testCannotRevokeOwnSessions(): void
{
$login = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withToken($login['token'], 'PUT', 'users', [
'action' => 'revokeSessions',
'userID' => $this->fixture()->adminUserId,
]);
$this->assertApiError($res, 'FORBIDDEN', 400);
}
}

181
tests/Support/ApiClient.php Normal file
View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
/**
* Dispatches requests through api/index.php (same as production routing).
*/
final class ApiClient
{
public function __construct(
private readonly string $apiIndexPath,
) {
}
/**
* @param array<string, string> $headers
* @return array{ok: bool, status: int, data?: mixed, error?: array<string, mixed>}
*/
public function request(
string $method,
string $route,
?array $body = null,
array $headers = [],
array $query = [],
): array {
qdb_test_reset_http_state();
$_SERVER['REQUEST_METHOD'] = strtoupper($method);
$queryString = $query !== [] ? '?' . http_build_query($query) : '';
$_SERVER['REQUEST_URI'] = '/api/' . ltrim($route, '/') . $queryString;
$_SERVER['SCRIPT_NAME'] = '/api/index.php';
$_GET = $query;
if ($queryString !== '') {
parse_str(ltrim($queryString, '?'), $_GET);
}
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['Authorization']);
unset($_SERVER['HTTP_X_QDB_CLIENT']);
foreach ($headers as $name => $value) {
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$_SERVER[$key] = $value;
}
if ($body !== null) {
qdb_test_set_request_body(json_encode($body, JSON_THROW_ON_ERROR));
}
ob_start();
try {
require $this->apiIndexPath;
ob_end_clean();
return ['ok' => false, 'status' => 500, 'error' => ['code' => 'NO_RESPONSE', 'message' => 'Handler did not return JSON']];
} catch (JsonSuccessResponse $e) {
$output = ob_get_clean();
return [
'ok' => true,
'status' => $e->httpStatus,
'data' => $e->data,
'_raw' => $output,
];
} catch (JsonErrorResponse $e) {
ob_get_clean();
$err = ['code' => $e->errorCode, 'message' => $e->errorMessage];
if ($e->details !== null) {
$err['details'] = $e->details;
}
return [
'ok' => false,
'status' => $e->httpStatus,
'error' => $err,
];
} catch (RawHttpResponse $e) {
ob_get_clean();
return [
'ok' => true,
'status' => $e->httpStatus,
'raw' => true,
'body' => $e->body,
'headers' => $e->headers,
];
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
}
public function loginWeb(string $username, string $password): array
{
return $this->request('POST', 'auth/login', [
'username' => $username,
'password' => $password,
], ['X-QDB-Client' => 'web']);
}
public function loginMobile(string $username, string $password): array
{
return $this->request('POST', 'auth/login', [
'username' => $username,
'password' => $password,
]);
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
public function loginMobileAndGetToken(string $username, string $password): array
{
$res = $this->loginMobile($username, $password);
if (!$res['ok']) {
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
}
$token = $res['data']['token'] ?? '';
if ($token === '') {
throw new \RuntimeException('Login response missing token');
}
return ['token' => $token, 'data' => $res['data']];
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
public function loginWebAndGetToken(string $username, string $password): array
{
$res = $this->loginWeb($username, $password);
if (!$res['ok']) {
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
}
$token = $res['data']['token'] ?? '';
if ($token === '') {
throw new \RuntimeException('Login response missing token');
}
return ['token' => $token, 'data' => $res['data']];
}
/**
* @param array<string, string> $extraHeaders
*/
public function withToken(string $token, string $method, string $route, ?array $body = null, array $query = [], array $extraHeaders = []): array
{
$headers = array_merge([
'Authorization' => 'Bearer ' . $token,
'X-QDB-Client' => 'web',
], $extraHeaders);
return $this->request($method, $route, $body, $headers, $query);
}
/**
* @param array<string, string> $extraHeaders
*/
public function withMobileToken(
string $token,
string $method,
string $route,
?array $body = null,
array $query = [],
array $extraHeaders = [],
): array {
$headers = array_merge(['Authorization' => 'Bearer ' . $token], $extraHeaders);
return $this->request($method, $route, $body, $headers, $query);
}
/**
* POST an encrypted payload (Android app API).
*
* @param array<string, mixed> $payload
*/
public function encryptedPost(string $token, string $route, array $payload): array
{
$envelope = qdb_sensitive_envelope(
json_encode($payload, JSON_THROW_ON_ERROR),
$token
);
return $this->withMobileToken($token, 'POST', $route, $envelope);
}
}

View File

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PDO;
/**
* Seeds a minimal org graph for integration tests.
*/
final class DatabaseSeeder
{
public const PASSWORD = 'TestPass1!';
public const ADMIN_USERNAME = 'test_admin';
public const SUPERVISOR_USERNAME = 'test_supervisor';
public const COACH_USERNAME = 'test_coach';
public static function seed(PDO $pdo): FixtureIds
{
$now = time();
$passwordHash = password_hash(self::PASSWORD, PASSWORD_DEFAULT);
// Stable IDs so fixture metadata always matches seeded rows.
$adminId = 'admin-test';
$adminUserId = 'u-admin-test';
$supervisorId = 'sv-test';
$supervisorUserId = 'u-sv-test';
$coachId = 'coach-test';
$coachUserId = 'u-coach-test';
$qnId = 'qn-test';
$questionId = $qnId . '__q1';
$optionId = 'opt-test-q1-yes';
$freeTextQuestionId = $qnId . '__q2';
$glassQuestionId = $qnId . '__q3';
$glassSymptomKey = 'pain';
$multiQuestionId = $qnId . '__q4';
$multiOptionAId = 'opt-test-q4-a';
$multiOptionBId = 'opt-test-q4-b';
$draftQuestionnaireId = 'qn-draft-test';
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $adminId, ':u' => self::ADMIN_USERNAME, ':loc' => 'HQ']);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $adminUserId,
':u' => self::ADMIN_USERNAME,
':h' => $passwordHash,
':role' => 'admin',
':eid' => $adminId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $supervisorId, ':u' => self::SUPERVISOR_USERNAME, ':loc' => 'Region A']);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $supervisorUserId,
':u' => self::SUPERVISOR_USERNAME,
':h' => $passwordHash,
':role' => 'supervisor',
':eid' => $supervisorId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
->execute([':id' => $coachId, ':sid' => $supervisorId, ':u' => self::COACH_USERNAME]);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $coachUserId,
':u' => self::COACH_USERNAME,
':h' => $passwordHash,
':role' => 'coach',
':eid' => $coachId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
->execute([':cc' => 'CLIENT-TEST-001', ':cid' => $coachId]);
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, 1, 0, :cj, :ck)'
)->execute([
':id' => $qnId,
':n' => 'Test questionnaire',
':v' => '1',
':s' => 'active',
':cj' => '{}',
':ck' => '',
]);
$pdo->prepare('UPDATE questionnaire SET showPoints = 1 WHERE questionnaireID = :id')
->execute([':id' => $qnId]);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 0, 1, :cfg)'
)->execute([
':id' => $questionId,
':qn' => $qnId,
':txt' => 'Consent?',
':type' => 'single_choice_question',
':cfg' => json_encode(['questionKey' => 'consent_q'], JSON_THROW_ON_ERROR),
]);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 0, 0, :next)'
)->execute([
':next' => '',
':id' => $optionId,
':qid' => $questionId,
':txt' => 'yes',
]);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 1, 0, :cfg)'
)->execute([
':id' => $freeTextQuestionId,
':qn' => $qnId,
':txt' => 'Notes',
':type' => 'free_text_question',
':cfg' => json_encode(['questionKey' => 'notes_q'], JSON_THROW_ON_ERROR),
]);
$glassConfig = json_encode(
['questionKey' => 'symptoms_q', 'symptoms' => [$glassSymptomKey, 'fatigue']],
JSON_THROW_ON_ERROR
);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 2, 0, :cfg)'
)->execute([
':id' => $glassQuestionId,
':qn' => $qnId,
':txt' => 'Symptoms',
':type' => 'glass_scale_question',
':cfg' => $glassConfig,
]);
require_once dirname(__DIR__, 2) . '/lib/settings.php';
qdb_settings_save_on_pdo($pdo, qdb_settings_defaults());
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 3, 0, :cfg)'
)->execute([
':id' => $multiQuestionId,
':qn' => $qnId,
':txt' => 'Pick several',
':type' => 'multi_check_box_question',
':cfg' => json_encode(['questionKey' => 'multi_q'], JSON_THROW_ON_ERROR),
]);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 1, 0, :next)'
)->execute([':next' => '', ':id' => $multiOptionAId, ':qid' => $multiQuestionId, ':txt' => 'opt_a']);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 2, 1, :next)'
)->execute([':next' => '', ':id' => $multiOptionBId, ':qid' => $multiQuestionId, ':txt' => 'opt_b']);
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionAId, 'A');
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionBId, 'B');
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, 2, 0, :cj, :ck)'
)->execute([
':id' => $draftQuestionnaireId,
':n' => 'Draft only',
':v' => '1',
':s' => 'draft',
':cj' => '{}',
':ck' => '',
]);
qdb_upsert_source_translation($pdo, 'answer_option', $optionId, 'Ja');
return new FixtureIds(
adminUserId: $adminUserId,
supervisorUserId: $supervisorUserId,
coachUserId: $coachUserId,
coachId: $coachId,
supervisorId: $supervisorId,
questionnaireId: $qnId,
questionId: $questionId,
questionShortId: 'q1',
optionId: $optionId,
freeTextQuestionId: $freeTextQuestionId,
freeTextShortId: 'q2',
glassQuestionId: $glassQuestionId,
glassShortId: 'q3',
glassSymptomKey: $glassSymptomKey,
multiCheckQuestionId: $multiQuestionId,
multiCheckShortId: 'q4',
draftQuestionnaireId: $draftQuestionnaireId,
clientCode: 'CLIENT-TEST-001',
);
}
}
final class FixtureIds
{
public function __construct(
public readonly string $adminUserId,
public readonly string $supervisorUserId,
public readonly string $coachUserId,
public readonly string $coachId,
public readonly string $supervisorId,
public readonly string $questionnaireId,
public readonly string $questionId,
public readonly string $questionShortId,
public readonly string $optionId,
public readonly string $freeTextQuestionId,
public readonly string $freeTextShortId,
public readonly string $glassQuestionId,
public readonly string $glassShortId,
public readonly string $glassSymptomKey,
public readonly string $multiCheckQuestionId,
public readonly string $multiCheckShortId,
public readonly string $draftQuestionnaireId,
public readonly string $clientCode,
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() when QDB_TESTING is defined. */
final class JsonErrorResponse extends RuntimeException
{
public function __construct(
public readonly string $errorCode,
public readonly string $errorMessage,
public readonly int $httpStatus = 400,
public readonly mixed $details = null,
) {
parent::__construct($errorMessage, $httpStatus);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() when QDB_TESTING is defined. */
final class JsonSuccessResponse extends RuntimeException
{
public function __construct(
public readonly mixed $data,
public readonly int $httpStatus = 200,
) {
parent::__construct('JSON success', $httpStatus);
}
}

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
abstract class QdbTestCase extends PHPUnitTestCase
{
protected static ApiClient $api;
protected ?FixtureIds $fixtureIds = null;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
self::$api = new ApiClient(dirname(__DIR__, 2) . '/api/index.php');
}
protected function setUp(): void
{
parent::setUp();
TestFilesystem::resetUploads();
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$this->bootstrapDatabase();
}
protected function bootstrapDatabase(): void
{
if (defined('QDB_PATH') && is_file(QDB_PATH)) {
@unlink(QDB_PATH);
}
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
@unlink(QDB_LOCK);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$this->fixtureIds = DatabaseSeeder::seed($pdo);
qdb_save($tmpDb, $lockFp);
}
protected function api(): ApiClient
{
return self::$api;
}
protected function fixture(): FixtureIds
{
if ($this->fixtureIds === null) {
$this->fail('Fixture not seeded');
}
return $this->fixtureIds;
}
protected function adminToken(): string
{
return $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
}
protected function supervisorToken(): string
{
return $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
protected function coachMobileLogin(): array
{
return $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
}
protected function assertApiOk(array $response, string $message = ''): void
{
$this->assertTrue($response['ok'] ?? false, $message . ' ' . json_encode($response['error'] ?? []));
}
protected function assertApiError(array $response, string $code, ?int $status = null): void
{
$this->assertFalse($response['ok'] ?? true);
$this->assertSame($code, $response['error']['code'] ?? null);
if ($status !== null) {
$this->assertSame($status, $response['status']);
}
}
protected function assertRawOk(array $response, string $contentTypePrefix): void
{
$this->assertTrue($response['ok'] ?? false);
$this->assertTrue($response['raw'] ?? false);
$this->assertStringStartsWith($contentTypePrefix, $response['headers']['Content-Type'] ?? '');
$this->assertNotSame('', $response['body'] ?? '');
}
/**
* @return array<string, mixed> API response from app submit
*/
protected function submitFixtureQuestionnaire(): array
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$now = time();
return $this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
],
$now - 60,
$now
);
}
/**
* @param list<array<string, mixed>> $answers
* @return array<string, mixed>
*/
protected function submitQuestionnaireAs(
string $token,
string $questionnaireId,
string $clientCode,
array $answers,
?int $startedAt = null,
?int $completedAt = null,
?int $structureRevision = null,
): array {
$now = time();
$startedAt ??= $now - 60;
$completedAt ??= $now;
$payload = [
'questionnaireID' => $questionnaireId,
'clientCode' => $clientCode,
'answers' => $answers,
'startedAt' => $startedAt,
'completedAt' => $completedAt,
];
if ($structureRevision !== null) {
$payload['structureRevision'] = $structureRevision;
}
return $this->api()->encryptedPost($token, 'app_questionnaires', $payload);
}
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int
{
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM questionnaire_submission WHERE clientCode = :cc AND questionnaireID = :qn'
);
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireId]);
$count = (int)$stmt->fetchColumn();
qdb_discard($tmpDb, $lockFp);
return $count;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() for file/download responses when QDB_TESTING is defined. */
final class RawHttpResponse extends RuntimeException
{
/**
* @param array<string, string> $headers
*/
public function __construct(
public readonly string $body,
public readonly array $headers,
public readonly int $httpStatus = 200,
) {
parent::__construct('Raw HTTP response', $httpStatus);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class TestFilesystem
{
public static function resetUploads(): void
{
if (!defined('QDB_TEST_UPLOADS')) {
return;
}
if (defined('QDB_PATH')) {
if (is_file(QDB_PATH)) {
@unlink(QDB_PATH);
}
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
@unlink(QDB_LOCK);
}
}
self::rmTree(QDB_TEST_UPLOADS);
mkdir(QDB_TEST_UPLOADS, 0755, true);
}
private static function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
self::rmTree($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}

109
tests/Unit/ApiLogTest.php Normal file
View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ApiLogTest extends TestCase
{
private string $logDir;
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/api_log.php';
$this->logDir = $_ENV['QDB_API_LOG_DIR'] ?? (QDB_TEST_UPLOADS . '/logs/api');
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0775, true);
}
$this->clearLogFiles();
unset($GLOBALS['qdb_api_log_ctx']);
}
protected function tearDown(): void
{
$this->clearLogFiles();
unset($GLOBALS['qdb_api_log_ctx']);
}
public function testClassifyActivity(): void
{
$this->assertNull(qdb_api_log_classify_activity('OPTIONS', 'web', 'users'));
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'users'));
$this->assertSame('app_change', qdb_api_log_classify_activity('POST', '', 'app_questionnaires'));
$this->assertSame('app_sync', qdb_api_log_classify_activity('GET', '', 'app_questionnaires'));
$this->assertSame('web_export', qdb_api_log_classify_activity('GET', 'web', 'export'));
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'backup'));
$this->assertTrue(qdb_api_log_is_website_download('backup'));
}
public function testRedactSensitiveFields(): void
{
$redacted = qdb_api_log_redact_value([
'username' => 'coach1',
'password' => 'secret',
'encrypted' => str_repeat('x', 40),
]);
$this->assertSame('coach1', $redacted['username']);
$this->assertSame('[REDACTED]', $redacted['password']);
$this->assertSame('[ENCRYPTED:40 chars]', $redacted['encrypted']);
}
public function testRedactQueryString(): void
{
$qs = qdb_api_log_redact_query_string('questionnaireID=qn1&token=abc&password=pw');
parse_str((string)$qs, $params);
$this->assertSame('qn1', $params['questionnaireID'] ?? null);
$this->assertSame('[REDACTED]', $params['token'] ?? null);
$this->assertSame('[REDACTED]', $params['password'] ?? null);
}
public function testTokenHintMasksMiddle(): void
{
$hint = qdb_api_log_token_hint('0123456789abcdef');
$this->assertStringContainsString('…', $hint);
$this->assertStringNotContainsString('56789', $hint);
}
public function testClientIpUsesForwardedFor(): void
{
$_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.50, 198.51.100.1';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$this->assertSame('203.0.113.50', qdb_api_log_client_ip());
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
}
public function testBeginFinishWritesWebChangeEntry(): void
{
$_SERVER['HTTP_X_QDB_CLIENT'] = 'web';
$_SERVER['REQUEST_URI'] = '/api/users';
$_SERVER['QUERY_STRING'] = '';
qdb_test_set_request_body(json_encode(['username' => 'new_coach'], JSON_THROW_ON_ERROR));
http_response_code(200);
qdb_api_log_begin('POST', 'users');
qdb_api_log_finish();
$date = date('Y-m-d');
$read = qdb_api_log_read_entries($date, 'web_change', 10);
$entries = $read['entries'];
$this->assertNotEmpty($entries);
$last = $entries[0];
$this->assertSame('web_change', $last['activity']);
$this->assertSame('POST', $last['method']);
$this->assertSame('users', $last['route']);
$changes = $last['changes'] ?? [];
$fields = array_column($changes, 'field');
$this->assertContains('username', $fields);
qdb_test_reset_http_state();
}
private function clearLogFiles(): void
{
foreach (glob($this->logDir . '/api-*.log') ?: [] as $path) {
@unlink($path);
}
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class AppAnswersTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
}
public function testEncodeMultiCheckDedupesAndSortsKeys(): void
{
$json = qdb_encode_multi_check_values(['b', 'a', 'b', '']);
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$this->assertEqualsCanonicalizing(['a', 'b'], $decoded);
}
public function testDecodeMultiCheckFromJsonArray(): void
{
$this->assertSame(['x', 'y'], qdb_decode_multi_check_values('["x","y"]'));
}
public function testDecodeMultiCheckFromCommaSeparated(): void
{
$this->assertSame(['one', 'two'], qdb_decode_multi_check_values('one, two'));
}
public function testBuildGlassSymptomJsonOmitsEmptyAndReturnsNullForNoSymptoms(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
$this->assertNull(qdb_build_glass_symptom_json([]));
$this->assertNull(qdb_build_glass_symptom_json(['pain' => '']));
$json = qdb_build_glass_symptom_json(['pain' => 'mild', 'fatigue' => 'severe']);
$this->assertSame(['pain' => 'mild', 'fatigue' => 'severe'], json_decode((string)$json, true));
}
public function testGroupAppSubmitAnswersMergesMultiCheck(): void
{
$answers = [
['questionID' => 'q1', 'answerOptionKey' => 'opt_a', 'answeredAt' => 100],
['questionID' => 'q1', 'answerOptionKey' => 'opt_b', 'answeredAt' => 100],
['questionID' => 'q2', 'freeTextValue' => 'hello'],
];
$grouped = qdb_group_app_submit_answers(
$answers,
['q1' => 'multi_check_box_question', 'q2' => 'text_question'],
[]
);
$this->assertCount(2, $grouped);
$this->assertSame('q1', $grouped[0]['questionID']);
$decoded = json_decode((string)$grouped[0]['freeTextValue'], true, 512, JSON_THROW_ON_ERROR);
$this->assertEqualsCanonicalizing(['opt_a', 'opt_b'], $decoded);
$this->assertSame('hello', $grouped[1]['freeTextValue']);
}
public function testGroupAppSubmitAnswersPreservesSymptomRows(): void
{
$answers = [
['questionID' => 'fatigue', 'answerOptionKey' => 'moderate'],
];
$grouped = qdb_group_app_submit_answers(
$answers,
['fatigue' => 'glass_scale_question'],
['fatigue' => 'glass_parent']
);
$this->assertCount(1, $grouped);
$this->assertSame('moderate', $grouped[0]['answerOptionKey']);
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\QdbTestCase;
final class AppSubmitValidateTest extends QdbTestCase
{
public function testEmptyAnswersRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$errors = qdb_validate_app_submit_payload($pdo, $f->questionnaireId, [], [], [], [], []);
qdb_discard($tmpDb, $lockFp);
$this->assertNotEmpty($errors);
$this->assertSame('ANSWERS_REQUIRED', $errors[0]['code']);
}
public function testUnknownQuestionRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => 'nope', 'answerOptionKey' => 'x']],
[],
[],
[],
[]
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
}
public function testUnknownQuestionAllowedInLegacyManifest(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$manifest = qdb_build_structure_manifest($pdo, $f->questionnaireId, true);
$maps = qdb_submit_maps_from_manifest($pdo, $f->questionnaireId, $manifest);
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
$maps['shortIdMap'],
$maps['shortIdToType'],
$maps['symptomParentMap'],
$maps['optionMap'],
$manifest
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame([], $errors);
}
public function testValidSingleChoiceWithOptionKeyPasses(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->questionShortId => $f->questionId];
$typeMap = [$f->questionShortId => 'single_choice_question'];
$optMap = [
$f->questionId => [
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
],
];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
$shortMap,
$typeMap,
[],
$optMap,
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame([], $errors);
}
public function testMultiCheckRequiresSelection(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->multiCheckShortId => $f->multiCheckQuestionId];
$typeMap = [$f->multiCheckShortId => 'multi_check_box_question'];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->multiCheckShortId, 'freeTextValue' => '']],
$shortMap,
$typeMap,
[],
[],
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('EMPTY_ANSWER', $errors[0]['code'] ?? '');
}
public function testInvalidOptionKeyRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->questionShortId => $f->questionId];
$typeMap = [$f->questionShortId => 'single_choice_question'];
$optMap = [
$f->questionId => [
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
],
];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'No']],
$shortMap,
$typeMap,
[],
$optMap,
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('INVALID_OPTION', $errors[0]['code'] ?? '');
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\JsonErrorResponse;
use Tests\Support\QdbTestCase;
final class CommonBundleTest extends QdbTestCase
{
public function testImportQuestionnairesBundleRejectsEmptyList(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
qdb_import_questionnaires_bundle($pdo, ['questionnaires' => []]);
$this->fail('Expected JsonErrorResponse');
} catch (JsonErrorResponse $e) {
$this->assertSame('MISSING_FIELDS', $e->errorCode);
} finally {
qdb_discard($tmpDb, $lockFp);
}
}
public function testImportTranslationsBundleRoundTripOnPdo(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
qdb_put_translation($pdo, 'question', $f->questionId, 'en', 'Hello EN');
qdb_save($tmpDb, $lockFp);
[$pdo2, $tmp2, $lock2] = qdb_open(false);
$bundle = qdb_export_translations_bundle($pdo2);
qdb_discard($tmp2, $lock2);
[$pdo3, $tmp3, $lock3] = qdb_open(true);
$pdo3->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lc')
->execute([':id' => $f->questionId, ':lc' => 'en']);
$result = qdb_import_translations_bundle($pdo3, $bundle);
qdb_save($tmp3, $lock3);
$this->assertGreaterThan(0, $result['imported'] ?? 0);
[$pdo4, $tmp4, $lock4] = qdb_open(false);
$stmt = $pdo4->prepare(
'SELECT text FROM question_translation WHERE questionID = :id AND languageCode = :lc'
);
$stmt->execute([':id' => $f->questionId, ':lc' => 'en']);
$this->assertSame('Hello EN', $stmt->fetchColumn());
qdb_discard($tmp4, $lock4);
}
public function testExportQuestionnaireBundleIncludesQuestionKeys(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$item = qdb_export_questionnaire_bundle($pdo, $f->questionnaireId);
qdb_discard($tmpDb, $lockFp);
$this->assertNotNull($item);
$keys = array_column($item['questions'], 'questionKey');
$this->assertContains('consent_q', $keys);
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class CommonHelpersTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/common.php';
}
public function testIsStableKey(): void
{
$this->assertTrue(qdb_is_stable_key('question_key'));
$this->assertTrue(qdb_is_stable_key('yes'));
$this->assertFalse(qdb_is_stable_key('Question Key'));
$this->assertFalse(qdb_is_stable_key(''));
}
public function testNormalizeStableKeyCoercesSpaces(): void
{
$this->assertSame('My_Key', qdb_normalize_stable_key('My Key'));
$this->assertSame('consent_q', qdb_normalize_stable_key('consent_q'));
}
public function testValidateStableKeyReturnsNormalized(): void
{
$this->assertSame('My_Key', qdb_validate_stable_key('My Key', 'Question key'));
}
public function testMessageKeyFromConditionJson(): void
{
$json = json_encode(['messageKey' => 'locked_hint'], JSON_THROW_ON_ERROR);
$this->assertSame('locked_hint', qdb_message_key_from_condition_json($json));
$this->assertSame('', qdb_message_key_from_condition_json('{}'));
}
public function testMergeGlassSymptomJson(): void
{
$merged = qdb_merge_glass_symptom_json('{"pain":"mild"}', ['fatigue' => 'severe']);
$data = json_decode($merged, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame('mild', $data['pain']);
$this->assertSame('severe', $data['fatigue']);
}
public function testGlassSymptomAnswerValue(): void
{
$json = json_encode(['pain' => 'moderate'], JSON_THROW_ON_ERROR);
$this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain'));
$this->assertSame('', qdb_glass_symptom_answer_value($json, 'missing'));
}
public function testApplyGlassSymptomsConfig(): void
{
$config = ['scaleType' => 'glass'];
$rows = [
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'fatigue', 'labelDe' => 'Müdigkeit'],
];
$out = qdb_apply_glass_symptoms_config($config, $rows);
$this->assertSame(['pain', 'fatigue'], $out['symptoms']);
$this->assertSame('glass', $out['scaleType']);
}
public function testApplyGlassSymptomsConfigClearsWhenEmpty(): void
{
$config = ['symptoms' => ['old_key'], 'scaleType' => 'thermometer'];
$out = qdb_apply_glass_symptoms_config($config, []);
$this->assertArrayNotHasKey('symptoms', $out);
}
public function testParseGlassSymptomsBody(): void
{
$body = [
'glassSymptoms' => [
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'invalid key', 'labelDe' => 'x'],
],
];
$rows = qdb_parse_glass_symptoms_body($body, []);
$this->assertSame([
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'invalid_key', 'labelDe' => 'x'],
], $rows);
}
public function testParseGlassSymptomsBodyFromConfigWhenMissing(): void
{
$config = ['symptoms' => ['fatigue', 'pain']];
$rows = qdb_parse_glass_symptoms_body([], $config);
$this->assertSame([
['key' => 'fatigue', 'labelDe' => ''],
['key' => 'pain', 'labelDe' => ''],
], $rows);
}
}

53
tests/Unit/CryptoTest.php Normal file
View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class CryptoTest extends TestCase
{
public function testAesRoundTrip(): void
{
$key = str_repeat('K', 32);
$plain = '{"clients":[{"clientCode":"C1"}]}';
$enc = aes256_cbc_encrypt_bytes($plain, $key);
$this->assertGreaterThan(16, strlen($enc));
$dec = aes256_cbc_decrypt_bytes($enc, $key);
$this->assertSame($plain, $dec);
}
public function testHkdfDeterministicForToken(): void
{
$token = bin2hex(random_bytes(16));
$k1 = hkdf_session_key_from_token($token);
$k2 = hkdf_session_key_from_token($token);
$this->assertSame(32, strlen($k1));
$this->assertSame($k1, $k2);
}
public function testHkdfDiffersForDifferentTokens(): void
{
$a = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
$b = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
$this->assertNotSame($a, $b);
}
public function testDecryptFailsWithWrongKey(): void
{
$key = str_repeat('K', 32);
$enc = aes256_cbc_encrypt_bytes('payload', $key);
$this->expectException(\Exception::class);
aes256_cbc_decrypt_bytes($enc, str_repeat('X', 32));
}
public function testDecryptFailsWhenCiphertextTampered(): void
{
$key = str_repeat('K', 32);
$enc = aes256_cbc_encrypt_bytes('payload', $key);
$enc[20] = $enc[20] === 'a' ? 'b' : 'a';
$this->expectException(\Exception::class);
aes256_cbc_decrypt_bytes($enc, $key);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class EncryptedPayloadTest extends TestCase
{
public function testEnvelopeRoundTrip(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$token = bin2hex(random_bytes(32));
$json = '{"clientCode":"X","answers":[]}';
$env = qdb_sensitive_envelope($json, $token);
$this->assertTrue($env['encrypted']);
$this->assertSame('A256GCM', $env['alg']);
$plain = qdb_decrypt_sensitive_envelope($env, $token);
$this->assertSame($json, $plain);
}
public function testLegacyCbcEnvelopeStillDecrypts(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$token = bin2hex(random_bytes(32));
$json = '{"legacy":true}';
$key = hkdf_session_key_from_token($token);
$env = [
'encrypted' => true,
'payload' => base64_encode(aes256_cbc_encrypt_bytes($json, $key)),
];
$this->assertSame($json, qdb_decrypt_sensitive_envelope($env, $token));
}
public function testWrongTokenFailsDecrypt(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$env = qdb_sensitive_envelope('{}', bin2hex(random_bytes(32)));
$this->expectException(\Exception::class);
qdb_decrypt_sensitive_envelope($env, bin2hex(random_bytes(32)));
}
public function testInvalidEnvelopeRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$this->expectException(InvalidArgumentException::class);
qdb_decrypt_sensitive_envelope(['encrypted' => false], bin2hex(random_bytes(8)));
}
}

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