commit f1caa9e681e39902f792d42f8257c7e561ae5ae0 Author: tom.hempel Date: Mon Jun 29 12:39:55 2026 +0200 initial prototype based on nat-as-server diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..bf175d4 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..746aa56 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/api/.htaccess b/api/.htaccess new file mode 100644 index 0000000..1d6713f --- /dev/null +++ b/api/.htaccess @@ -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] diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000..9ebee27 --- /dev/null +++ b/api/index.php @@ -0,0 +1,91 @@ + 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"); +} diff --git a/bw-schuetzt-schema.drawio.png b/bw-schuetzt-schema.drawio.png new file mode 100644 index 0000000..09edc09 Binary files /dev/null and b/bw-schuetzt-schema.drawio.png differ diff --git a/cli/cleanup_sessions.php b/cli/cleanup_sessions.php new file mode 100644 index 0000000..76e170c --- /dev/null +++ b/cli/cleanup_sessions.php @@ -0,0 +1,21 @@ +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"; diff --git a/cli/diagnose_db.php b/cli/diagnose_db.php new file mode 100644 index 0000000..347d08a --- /dev/null +++ b/cli/diagnose_db.php @@ -0,0 +1,156 @@ + 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 \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); +} diff --git a/cli/diagnose_login.php b/cli/diagnose_login.php new file mode 100644 index 0000000..fae7372 --- /dev/null +++ b/cli/diagnose_login.php @@ -0,0 +1,77 @@ + + */ +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 \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); +} diff --git a/common.php b/common.php new file mode 100644 index 0000000..b019ae8 --- /dev/null +++ b/common.php @@ -0,0 +1,2398 @@ + + */ +$GLOBALS['qdb_dotenv'] = []; + +function qdb_env_file_path(): string { + return __DIR__ . '/.env'; +} + +/** + * Parse a dotenv file. Does not use getenv/putenv. + * Not cached when unreadable (FPM workers can start before .env exists). + * + * @return array + */ +function qdb_parse_env_file(string $path): array { + static $cache = []; + $mtime = @filemtime($path) ?: 0; + $cacheKey = $path . "\0" . $mtime; + if ($mtime > 0 && isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } + $out = []; + if (!is_readable($path)) { + return $out; + } + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return $out; + } + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + $eq = strpos($line, '='); + if ($eq === false) { + continue; + } + $name = trim(substr($line, 0, $eq)); + $value = trim(substr($line, $eq + 1)); + if ($name === '') { + continue; + } + $len = strlen($value); + if ($len >= 2) { + $q = $value[0]; + if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) { + $value = substr($value, 1, -1); + } + } + $out[$name] = $value; + } + if ($mtime > 0) { + $cache[$cacheKey] = $out; + } + return $out; +} + +/** + * Read an environment variable (.env file first, then getenv / $_ENV / $_SERVER). + */ +function qdb_env_get(string $name): ?string { + $fileVars = qdb_parse_env_file(qdb_env_file_path()); + if (isset($fileVars[$name]) && $fileVars[$name] !== '') { + return $fileVars[$name]; + } + if (isset($GLOBALS['qdb_dotenv'][$name]) && $GLOBALS['qdb_dotenv'][$name] !== '') { + return $GLOBALS['qdb_dotenv'][$name]; + } + // Do not use getenv() for QDB_MASTER_KEY — FPM can retain a stale system value + // when .env was missing on the worker's first request. + if ($name === 'QDB_MASTER_KEY') { + return null; + } + $v = getenv($name); + if ($v !== false && $v !== '') { + return $v; + } + if (isset($_ENV[$name]) && $_ENV[$name] !== '') { + return (string)$_ENV[$name]; + } + if (isset($_SERVER[$name]) && $_SERVER[$name] !== '') { + return (string)$_SERVER[$name]; + } + return null; +} + +/** Set an environment variable for the current request (best-effort putenv). */ +function qdb_env_set(string $name, string $value): void { + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + if (function_exists('putenv')) { + @putenv("$name=$value"); + } +} + +/** Load KEY=value pairs from .env into $GLOBALS['qdb_dotenv'] and $_ENV. */ +function qdb_load_dotenv(string $path): void { + foreach (qdb_parse_env_file($path) as $name => $value) { + $GLOBALS['qdb_dotenv'][$name] = $value; + qdb_env_set($name, $value); + } +} + +qdb_load_dotenv(qdb_env_file_path()); + +// --- MASTER-KEY (Server-seitig zum Speichern der DB) --- +// Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String). +function get_master_key_bytes(): string { + $env = qdb_env_get('QDB_MASTER_KEY'); + if ($env === null) { + $hint = is_readable(qdb_env_file_path()) ? 'missing QDB_MASTER_KEY in .env' + : '.env not readable by PHP (' . qdb_env_file_path() . ')'; + throw new RuntimeException('QDB_MASTER_KEY is not set. ' . $hint); + } + $b = base64_decode($env, true); + if ($b !== false && strlen($b) > 0) { + return str_pad(substr($b, 0, 32), 32, "\0"); + } + return str_pad(substr($env, 0, 32), 32, "\0"); +} + +// --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) --- +// Kompatibel zu Android-Implementierung: salt = leer, info = "qdb-aes" +function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes', int $len = 32): string { + $ikm = @hex2bin(trim($tokenHex)); + if ($ikm === false) $ikm = $tokenHex; // Falls bereits binär + if (function_exists('hash_hkdf')) { + return hash_hkdf('sha256', $ikm, $len, $info, ''); + } + // Fallback: eigene HKDF-Implementierung + $hashLen = 32; + $salt = str_repeat("\0", $hashLen); + $prk = hash_hmac('sha256', $ikm, $salt, true); + $okm = ''; + $t = ''; + $n = (int)ceil($len / $hashLen); + for ($i = 1; $i <= $n; $i++) { + $t = hash_hmac('sha256', $t . $info . chr($i), $prk, true); + $okm .= $t; + } + return substr($okm, 0, $len); +} + +// --- Token-Storage (DB-backed via session table) --- + +function token_storage_key(string $token): string { + return hash('sha256', $token); +} + +function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void { + require_once __DIR__ . '/db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $now = time(); + $pdo->prepare( + "INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp) + VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)" + )->execute([ + ':t' => token_storage_key($token), + ':uid' => $extra['userID'] ?? '', + ':role' => $extra['role'] ?? '', + ':eid' => $extra['entityID'] ?? '', + ':ca' => $now, + ':ea' => $now + $ttlSeconds, + ':tmp' => (int)(!empty($extra['temp'])), + ]); + // Opportunistic cleanup: remove expired sessions + $pdo->prepare("DELETE FROM session WHERE expiresAt < :now")->execute([':now' => $now]); + qdb_save($tmpDb, $lockFp); +} + +function token_get_record(string $token, bool $refreshExpiry = true): ?array { + require_once __DIR__ . '/db_init.php'; + require_once __DIR__ . '/lib/settings.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open($refreshExpiry); + $now = time(); + $stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now"); + $storedToken = token_storage_key($token); + $stmt->execute([':t' => $storedToken, ':now' => $now]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + qdb_discard($tmpDb, $lockFp); + return null; + } + if ($refreshExpiry) { + $settings = qdb_settings_get_on_pdo($pdo); + $newExpiry = $now + qdb_session_ttl_seconds($settings, !empty($row['temp'])); + $pdo->prepare('UPDATE session SET expiresAt = :exp WHERE token = :t') + ->execute([':exp' => $newExpiry, ':t' => $storedToken]); + $row['expiresAt'] = $newExpiry; + qdb_save($tmpDb, $lockFp); + } else { + qdb_discard($tmpDb, $lockFp); + } + // Map DB columns to the keys the rest of the codebase expects + return [ + 'token' => $row['token'], + 'role' => $row['role'], + 'entityID' => $row['entityID'], + 'userID' => $row['userID'], + 'created' => (int)$row['createdAt'], + 'exp' => (int)$row['expiresAt'], + 'temp' => (int)$row['temp'], + ]; +} + +function token_revoke(string $token): void { + require_once __DIR__ . '/db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => token_storage_key($token)]); + qdb_save($tmpDb, $lockFp); +} + +/** Revoke all sessions for one user on an open writable connection. */ +function token_revoke_all_for_user_on_pdo(PDO $pdo, string $userID): int { + if ($userID === '') { + return 0; + } + $stmt = $pdo->prepare('DELETE FROM session WHERE userID = :uid'); + $stmt->execute([':uid' => $userID]); + return $stmt->rowCount(); +} + +/** Revoke every login session for a user (after password change or admin reset). */ +function token_revoke_all_for_user(string $userID): int { + if ($userID === '') { + return 0; + } + require_once __DIR__ . '/db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $removed = token_revoke_all_for_user_on_pdo($pdo, $userID); + qdb_save($tmpDb, $lockFp); + return $removed; +} + +/** Revoke every stored session (all users and devices). */ +function token_revoke_all_on_pdo(PDO $pdo): int +{ + if (!qdb_table_exists($pdo, 'session')) { + return 0; + } + $count = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn(); + $pdo->exec('DELETE FROM session'); + return $count; +} + +function token_revoke_all(): int +{ + require_once __DIR__ . '/db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $removed = token_revoke_all_on_pdo($pdo); + qdb_save($tmpDb, $lockFp); + return $removed; +} + +// --- Auth helpers for endpoints --- +function get_bearer_token(): ?string { + $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? ''); + if (stripos($auth, 'Bearer ') === 0) { + return substr($auth, 7); + } + return null; +} + +function require_valid_token(): array { + $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); + } + if (!empty($rec['temp'])) { + json_error('PASSWORD_CHANGE_REQUIRED', 'Password change required before access', 403); + } + $rec['_token'] = $token; + return $rec; +} + +function require_role(array $allowed, array $tokenRecord): void { + $role = $tokenRecord['role'] ?? ''; + if (!in_array($role, $allowed, true)) { + json_error('FORBIDDEN', 'Insufficient permissions', 403); + } +} + +/** Roles that may use the management website (not the mobile app). */ +function qdb_web_roles(): array { + return ['admin', 'supervisor']; +} + +/** True when the request originates from the management website (see website/js/api.js). */ +function qdb_is_web_client_request(): bool { + $h = $_SERVER['HTTP_X_QDB_CLIENT'] ?? ''; + return strtolower(trim($h)) === 'web'; +} + +/** Website or coordinator mobile app — not the counselor field app. */ +function qdb_is_management_client_request(): bool { + $h = strtolower(trim($_SERVER['HTTP_X_QDB_CLIENT'] ?? '')); + return in_array($h, ['web', 'coordinator'], true); +} + +/** Reject coach sign-in from management clients; coaches use the field app only. */ +function qdb_reject_coach_web_login(string $role): void { + if ($role === 'coach' && qdb_is_management_client_request()) { + json_error( + 'FORBIDDEN', + 'Counselor accounts can only sign in through the mobile app.', + 403 + ); + } +} + +/** Valid bearer token plus admin/supervisor role (website API). */ +function require_valid_token_web(): array { + $rec = require_valid_token(); + require_role(qdb_web_roles(), $rec); + return $rec; +} + +/** + * Build a SQL WHERE clause fragment that restricts client visibility by role. + * Returns [string $clause, array $params]. + * $clientAlias is the table alias or name that has a coachID column (e.g. 'client'). + */ +/** + * @param string $archiveMode active — non-archived only (default); archived — archived only; all — no archive filter + */ +function rbac_client_filter( + array $tokenRecord, + string $clientAlias = 'client', + string $archiveMode = 'active' +): array { + $role = $tokenRecord['role'] ?? ''; + $entityID = $tokenRecord['entityID'] ?? ''; + switch ($role) { + case 'admin': + $clause = '1=1'; + $params = []; + break; + case 'supervisor': + $clause = "$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)"; + $params = [':rbac_eid' => $entityID]; + break; + case 'coach': + $clause = "$clientAlias.coachID = :rbac_eid"; + $params = [':rbac_eid' => $entityID]; + break; + default: + return ['0=1', []]; + } + + $archiveClause = match ($archiveMode) { + 'archived' => "COALESCE($clientAlias.archived, 0) = 1", + 'all' => '1=1', + default => "COALESCE($clientAlias.archived, 0) = 0", + }; + + return ["($clause) AND ($archiveClause)", $params]; +} + +// --- Translations: German (de) is the canonical source language --- +define('QDB_SOURCE_LANGUAGE', 'de'); + +function qdb_ensure_source_language(PDO $pdo): void { + $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) + ON CONFLICT(languageCode) DO NOTHING") + ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); +} + +function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void { + qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text); +} + +/** Catalog of global app UI string keys (LanguageManager). */ +function qdb_app_string_catalog(bool $reload = false): array { + static $catalog = null; + if (!$reload && $catalog !== null) { + return $catalog; + } + $path = qdb_app_string_catalog_path(); + if (!is_readable($path)) { + $catalog = ['keys' => [], 'germanDefaults' => []]; + return $catalog; + } + $data = json_decode(file_get_contents($path), true); + $catalog = is_array($data) ? $data : ['keys' => [], 'germanDefaults' => []]; + return $catalog; +} + +function qdb_app_string_catalog_path(): string { + return __DIR__ . '/data/app_ui_strings.json'; +} + +/** Remove questionnaire_group_* from catalog file (keys list + germanDefaults). */ +function qdb_remove_questionnaire_group_from_catalog(string $stringKey): void { + if (!qdb_is_questionnaire_group_string_key($stringKey)) { + return; + } + $path = qdb_app_string_catalog_path(); + if (!is_readable($path) || !is_writable($path)) { + return; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return; + } + $changed = false; + if (!empty($data['keys']) && is_array($data['keys'])) { + $next = array_values(array_filter( + $data['keys'], + fn($k) => (string)$k !== $stringKey + )); + if (count($next) !== count($data['keys'])) { + $data['keys'] = $next; + $changed = true; + } + } + if (!empty($data['germanDefaults']) && is_array($data['germanDefaults']) && array_key_exists($stringKey, $data['germanDefaults'])) { + unset($data['germanDefaults'][$stringKey]); + $changed = true; + } + if (!$changed) { + return; + } + file_put_contents( + $path, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", + LOCK_EX + ); + qdb_app_string_catalog(true); +} + +function qdb_app_string_key_set(): array { + $keys = qdb_app_string_catalog()['keys'] ?? []; + return array_flip($keys); +} + +/** Keys for dev-only UI (database export, etc.) — excluded from website App UI and app sync catalog. */ +function qdb_dev_only_app_string_key_set(): array { + static $set = null; + if ($set !== null) { + return $set; + } + $set = array_flip([ + 'database', + 'database_clients_title', + 'download_header', + 'export_success_downloads', + 'export_failed', + 'headers', + 'view_missing', + 'no_header_template_found', + 'saved_pdf_csv', + 'no_pdf_viewer', + 'save_error', + 'no_clients_available', + 'questionnaire_id', + 'questionnaires', + 'status', + 'data', + 'data_final_warning', + 'open_client_via_load', + 'ask_before_upload', + 'You have completed questionnaires that have not been uploaded yet.', + 'You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.', + ]); + return $set; +} + +/** Prefix for app UI strings that label questionnaire category groups on the opening screen. */ +function qdb_questionnaire_group_prefix(): string { + return 'questionnaire_group_'; +} + +function qdb_questionnaire_group_string_key(string $categoryKey): string { + $categoryKey = trim($categoryKey); + return $categoryKey === '' ? '' : qdb_questionnaire_group_prefix() . $categoryKey; +} + +function qdb_is_questionnaire_group_string_key(string $stringKey): bool { + return str_starts_with($stringKey, qdb_questionnaire_group_prefix()); +} + +function qdb_category_key_from_group_string_key(string $stringKey): string { + $prefix = qdb_questionnaire_group_prefix(); + return str_starts_with($stringKey, $prefix) ? substr($stringKey, strlen($prefix)) : ''; +} + +/** @return array categoryKey => questionnaire count */ +function qdb_category_keys_in_use(PDO $pdo): array { + $map = []; + $rows = $pdo->query( + "SELECT categoryKey, COUNT(*) AS c FROM questionnaire + WHERE trim(categoryKey) != '' GROUP BY categoryKey" + )->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $k = trim((string)($row['categoryKey'] ?? '')); + if ($k !== '') { + $map[$k] = (int)$row['c']; + } + } + return $map; +} + +/** + * App string entries for category keys in use (not already in the static JSON catalog keys list). + * @return list + */ +function qdb_category_group_string_entries(PDO $pdo): array { + $catalog = qdb_app_string_catalog(); + $defaults = $catalog['germanDefaults'] ?? []; + $staticKeys = qdb_app_string_key_set(); + $entries = []; + $order = 0; + foreach (qdb_category_keys_in_use($pdo) as $catKey => $_count) { + $stringKey = qdb_questionnaire_group_string_key($catKey); + if ($stringKey === '' || isset($staticKeys[$stringKey])) { + continue; + } + $entries[] = [ + 'key' => $stringKey, + 'displayKey' => $stringKey, + 'type' => 'app_string', + 'entityId' => $stringKey, + 'sortOrder' => $order++, + 'germanDefault' => $defaults[$stringKey] ?? $catKey, + 'categoryKey' => $catKey, + ]; + } + usort($entries, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey'])); + return $entries; +} + +/** + * Category keys with translations in DB but no questionnaire using them. + * @return list + */ +function qdb_orphaned_category_keys(PDO $pdo): array { + $inUse = qdb_category_keys_in_use($pdo); + $prefix = qdb_questionnaire_group_prefix(); + $orphans = []; + $stmt = $pdo->query( + "SELECT DISTINCT stringKey FROM string_translation WHERE stringKey LIKE " . $pdo->quote($prefix . '%') + ); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $stringKey) { + $catKey = qdb_category_key_from_group_string_key((string)$stringKey); + if ($catKey !== '' && !isset($inUse[$catKey])) { + $orphans[$catKey] = true; + } + } + ksort($orphans); + return array_keys($orphans); +} + +/** + * @return list + */ +function qdb_list_category_keys_for_dashboard(PDO $pdo): array { + $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; + $inUse = qdb_category_keys_in_use($pdo); + $list = []; + + $loadGerman = function (string $stringKey) use ($pdo, $defaults): string { + $stmt = $pdo->prepare( + 'SELECT text FROM string_translation WHERE stringKey = :k AND languageCode = :lang LIMIT 1' + ); + $stmt->execute([':k' => $stringKey, ':lang' => QDB_SOURCE_LANGUAGE]); + $text = $stmt->fetchColumn(); + if (is_string($text) && $text !== '') { + return $text; + } + return $defaults[$stringKey] ?? ''; + }; + + foreach ($inUse as $catKey => $count) { + $stringKey = qdb_questionnaire_group_string_key($catKey); + $list[] = [ + 'categoryKey' => $catKey, + 'stringKey' => $stringKey, + 'questionnaireCount' => $count, + 'orphaned' => false, + 'germanLabel' => $loadGerman($stringKey) ?: $catKey, + ]; + } + + foreach (qdb_orphaned_category_keys($pdo) as $catKey) { + $stringKey = qdb_questionnaire_group_string_key($catKey); + $list[] = [ + 'categoryKey' => $catKey, + 'stringKey' => $stringKey, + 'questionnaireCount' => 0, + 'orphaned' => true, + 'germanLabel' => $loadGerman($stringKey) ?: $catKey, + ]; + } + + usort($list, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey'])); + return $list; +} + +/** Persist default German for a questionnaire_group_* key in app_ui_strings.json. */ +function qdb_update_questionnaire_group_german_default(string $stringKey, string $text): void { + if (!qdb_is_questionnaire_group_string_key($stringKey)) { + return; + } + $path = qdb_app_string_catalog_path(); + if (!is_readable($path) || !is_writable($path)) { + return; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return; + } + if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) { + $data['germanDefaults'] = []; + } + if (($data['germanDefaults'][$stringKey] ?? '') === $text) { + return; + } + $data['germanDefaults'][$stringKey] = $text; + file_put_contents( + $path, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", + LOCK_EX + ); + qdb_app_string_catalog(true); +} + +/** Set German label for a category (app opening screen group title). */ +function qdb_set_category_german_label(PDO $pdo, string $categoryKey, string $text): string { + $categoryKey = trim($categoryKey); + if ($categoryKey === '') { + json_error('INVALID_FIELD', 'categoryKey is required', 400); + } + $text = trim($text); + if ($text === '') { + json_error('INVALID_FIELD', 'German label is required', 400); + } + $stringKey = qdb_questionnaire_group_string_key($categoryKey); + qdb_set_app_string_german_label($pdo, $stringKey, $text); + qdb_update_questionnaire_group_german_default($stringKey, $text); + return $stringKey; +} + +/** + * Remove a category key completely: clear questionnaires, translations, and catalog defaults. + * @return int questionnaires updated + */ +function qdb_delete_category_key(PDO $pdo, string $categoryKey): int { + $categoryKey = trim($categoryKey); + if ($categoryKey === '') { + json_error('INVALID_FIELD', 'categoryKey is required', 400); + } + $stringKey = qdb_questionnaire_group_string_key($categoryKey); + + $stmt = $pdo->prepare('UPDATE questionnaire SET categoryKey = \'\' WHERE categoryKey = :ck'); + $stmt->execute([':ck' => $categoryKey]); + $cleared = $stmt->rowCount(); + + $pdo->prepare('DELETE FROM string_translation WHERE stringKey = :k') + ->execute([':k' => $stringKey]); + + qdb_remove_questionnaire_group_from_catalog($stringKey); + + return $cleared; +} + +/** + * Global app UI strings (screens, toasts, auth, etc.) for the mobile app. + * @return list + */ +function qdb_app_ui_string_entries(): array { + $catalog = qdb_app_string_catalog(); + $defaults = $catalog['germanDefaults'] ?? []; + $entries = []; + $order = 0; + foreach ($catalog['keys'] ?? [] as $key) { + if (qdb_is_questionnaire_group_string_key($key)) { + continue; + } + $entries[] = [ + 'key' => $key, + 'displayKey' => $key, + 'type' => 'app_string', + 'entityId' => $key, + 'sortOrder' => $order++, + 'germanDefault' => $defaults[$key] ?? $key, + ]; + } + return $entries; +} + +/** + * Static catalog plus questionnaire_group_* keys for categoryKey values in use. + * @return list + */ +/** messageKey field inside questionnaire conditionJson (app locked hint). */ +function qdb_message_key_from_condition_json(?string $json): string { + if ($json === null || trim($json) === '' || trim($json) === '{}') { + return ''; + } + $obj = json_decode($json, true); + if (!is_array($obj)) { + return ''; + } + $key = trim((string)($obj['messageKey'] ?? '')); + return $key !== '' && qdb_is_stable_key($key) ? $key : ''; +} + +/** @return array */ +function qdb_collect_condition_message_keys(PDO $pdo): array { + $keys = []; + foreach ($pdo->query('SELECT conditionJson FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN) as $json) { + $mk = qdb_message_key_from_condition_json($json); + if ($mk !== '') { + $keys[$mk] = true; + } + } + return $keys; +} + +/** + * App string entries for condition messageKey values referenced in questionnaire conditions. + * @return list + */ +function qdb_condition_message_string_entries(PDO $pdo): array { + $staticKeys = qdb_app_string_key_set(); + $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; + $entries = []; + $order = 0; + foreach (array_keys(qdb_collect_condition_message_keys($pdo)) as $key) { + if (isset($staticKeys[$key])) { + continue; + } + $entries[] = [ + 'key' => $key, + 'displayKey' => $key, + 'type' => 'app_string', + 'entityId' => $key, + 'sortOrder' => $order++, + 'germanDefault' => $defaults[$key] ?? $key, + ]; + } + usort($entries, fn($a, $b) => strcmp($a['key'], $b['key'])); + return $entries; +} + +function qdb_update_app_string_german_default(string $stringKey, string $text): void { + $path = qdb_app_string_catalog_path(); + if (!is_readable($path) || !is_writable($path)) { + return; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return; + } + if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) { + $data['germanDefaults'] = []; + } + if (($data['germanDefaults'][$stringKey] ?? '') === $text) { + return; + } + $data['germanDefaults'][$stringKey] = $text; + file_put_contents( + $path, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", + LOCK_EX + ); + qdb_app_string_catalog(true); +} + +function qdb_set_app_string_german_label(PDO $pdo, string $stringKey, string $text): void { + $stringKey = qdb_validate_stable_key($stringKey, 'messageKey'); + $text = trim($text); + if ($text === '') { + json_error('INVALID_FIELD', 'German label is required', 400); + } + qdb_put_translation($pdo, 'app_string', $stringKey, QDB_SOURCE_LANGUAGE, $text); + qdb_update_app_string_german_default($stringKey, $text); +} + +function qdb_app_ui_string_entries_merged(PDO $pdo): array { + $merged = qdb_app_ui_string_entries(); + $existing = []; + foreach ($merged as $e) { + $existing[$e['key']] = true; + } + $order = count($merged); + foreach (qdb_category_group_string_entries($pdo) as $e) { + if (!isset($existing[$e['key']])) { + $e['sortOrder'] = $order++; + $merged[] = $e; + $existing[$e['key']] = true; + } + } + foreach (qdb_condition_message_string_entries($pdo) as $e) { + if (!isset($existing[$e['key']])) { + $e['sortOrder'] = $order++; + $merged[] = $e; + $existing[$e['key']] = true; + } + } + return $merged; +} + +/** + * All string keys referenced by questionnaire content (questions, options, config). + * @return array + */ +function qdb_all_questionnaire_translation_key_set(PDO $pdo): array { + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $keys = []; + $rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $dbQ) { + $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; + $qk = qdb_question_key($config, $dbQ['defaultText']); + if ($qk !== '') { + $keys[$qk] = true; + if (!empty($config['noteBefore'])) { + $keys[qdb_note_before_key($qk)] = true; + } + if (!empty($config['noteAfter'])) { + $keys[qdb_note_after_key($qk)] = true; + } + } elseif ($dbQ['defaultText'] !== '') { + $keys[$dbQ['defaultText']] = true; + } + foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { + if (!empty($config[$field])) { + $keys[$config[$field]] = true; + } + } + if (!empty($config['symptoms']) && is_array($config['symptoms'])) { + foreach ($config['symptoms'] as $s) { + if ($s !== '') { + $keys[$s] = true; + } + } + } + if (!empty($config['options']) && is_array($config['options'])) { + foreach ($config['options'] as $opt) { + if (is_string($opt) && $opt !== '') { + $keys[$opt] = true; + } elseif (is_array($opt) && !empty($opt['key'])) { + $keys[$opt['key']] = true; + } + } + } + } + + foreach ($pdo->query("SELECT defaultText FROM answer_option")->fetchAll(PDO::FETCH_COLUMN) as $text) { + if ($text !== '') { + $keys[$text] = true; + } + } + + $cache = $keys; + return $cache; +} + +/** + * App UI catalog for the website Translations page (coach-visible, no dev-only keys). + * Catalog keys always appear here even when also used inside a questionnaire (e.g. "save"). + */ +function qdb_app_ui_string_entries_for_website(PDO $pdo): array { + $dev = qdb_dev_only_app_string_key_set(); + $entries = []; + foreach (qdb_app_ui_string_entries_merged($pdo) as $e) { + if (!isset($dev[$e['key']])) { + $entries[] = $e; + } + } + return $entries; +} + +/** Upsert one translation row; sync German text into defaultText for questions/options. */ +function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void { + if ($type === 'question') { + $pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text) + VALUES (:id, :lang, :t) + ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text") + ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); + if ($lang === QDB_SOURCE_LANGUAGE) { + $pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id") + ->execute([':t' => $text, ':id' => $id]); + } + } elseif ($type === 'answer_option') { + $pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text) + VALUES (:id, :lang, :t) + ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") + ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); + // defaultText holds optionKey; German label lives only in answer_option_translation. + } elseif ($type === 'string' || $type === 'app_string') { + $pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text) + VALUES (:id, :lang, :t) + ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text") + ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); + } +} + +/** Short label for translation UI (Key column); full text stays in `key` for API lookup. */ +function qdb_translation_display_key(array $e): string { + return qdb_translation_row_label($e); +} + +function qdb_short_entity_label(string $entityId): string { + $pos = strrpos($entityId, '__'); + return $pos !== false ? substr($entityId, $pos + 2) : $entityId; +} + +function qdb_require_non_empty_german(string $text, string $label = 'German text'): void { + if ($text === '') { + json_error('MISSING_FIELDS', "$label is required", 400); + } +} + +/** Stable identifier: snake_case starting with a letter (case preserved for LM keys). */ +function qdb_is_stable_key(string $s): bool { + return $s !== '' && (bool)preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $s); +} + +/** + * Coerce an arbitrary key string into a stable identifier (letters, digits, underscores; + * must start with a letter). Used on import and API writes so bundles are not limited to + * pre-validated keys (e.g. numeric option labels "1", "42"). + */ +function qdb_normalize_stable_key(string $s): string { + $s = trim($s); + if ($s === '') { + return ''; + } + if (qdb_is_stable_key($s)) { + return $s; + } + $out = preg_replace('/[^a-zA-Z0-9_]+/', '_', $s); + $out = preg_replace('/_+/', '_', $out ?? ''); + $out = trim($out, '_'); + if ($out === '') { + return 'k_' . substr(hash('crc32b', $s), 0, 8); + } + if (!preg_match('/^[a-zA-Z]/', $out)) { + $out = 'k_' . $out; + } + return qdb_is_stable_key($out) ? $out : 'k_' . substr(hash('crc32b', $s), 0, 8); +} + +function qdb_validate_stable_key(string $s, string $label = 'Key'): string { + $s = trim($s); + if ($s === '') { + json_error('INVALID_FIELD', "$label is required", 400); + } + $normalized = qdb_normalize_stable_key($s); + if (!qdb_is_stable_key($normalized)) { + json_error('INVALID_FIELD', "$label could not be normalized to a valid key", 400); + } + return $normalized; +} + +/** Resolve app lookup key for a question row. */ +function qdb_question_key(array $config, string $defaultText): string { + $key = trim((string)($config['questionKey'] ?? '')); + if ($key !== '' && qdb_is_stable_key($key)) { + return $key; + } + if (qdb_is_stable_key($defaultText)) { + return $defaultText; + } + return ''; +} + +/** Option key is stored in answer_option.defaultText. */ +function qdb_option_key(array $aoRow): string { + $dt = trim((string)($aoRow['defaultText'] ?? '')); + return qdb_is_stable_key($dt) ? $dt : ''; +} + +/** Human-readable text for glass-scale answers stored as JSON on the parent question row. */ +function qdb_format_glass_answer_json(?string $raw): string +{ + if ($raw === null || trim($raw) === '') { + return ''; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $raw; + } + $parts = []; + foreach ($decoded as $key => $val) { + $val = trim((string)$val); + if ($val === '') { + continue; + } + $parts[] = $key . ': ' . $val; + } + return implode('; ', $parts); +} + +/** + * Map glass symptom string keys to parent questionID for one questionnaire. + * + * @return array symptomKey => parentQuestionID + */ +function qdb_glass_symptom_parent_map(PDO $pdo, string $qnID): array +{ + $stmt = $pdo->prepare( + "SELECT questionID, configJson FROM question WHERE questionnaireID = :qn AND type = 'glass_scale_question'" + ); + $stmt->execute([':qn' => $qnID]); + $map = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cfg = json_decode($row['configJson'] ?? '{}', true) ?: []; + foreach ($cfg['symptoms'] ?? [] as $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk !== '') { + $map[$sk] = $row['questionID']; + } + } + } + return $map; +} + +/** Merge symptom => label selections into JSON for one glass-scale parent question. */ +function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabels): string +{ + $data = []; + if ($existingJson !== null && trim($existingJson) !== '') { + $decoded = json_decode($existingJson, true); + if (is_array($decoded)) { + $data = $decoded; + } + } + foreach ($symptomLabels as $key => $label) { + $k = trim((string)$key); + $v = trim((string)$label); + if ($k !== '' && $v !== '') { + $data[$k] = $v; + } + } + return json_encode($data, JSON_UNESCAPED_UNICODE); +} + +/** Single symptom value from glass-scale JSON stored on the parent question row. */ +function qdb_glass_symptom_answer_value(?string $json, string $symptomKey): string +{ + $symptomKey = trim($symptomKey); + if ($symptomKey === '' || $json === null || trim($json) === '') { + return ''; + } + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + return ''; + } + return trim((string)($decoded[$symptomKey] ?? '')); +} + +/** + * Flat columns for results / CSV: one column per glass-scale symptom. + * + * @return list + */ +function qdb_results_export_columns(array $questions, string $qnID): array +{ + $columns = []; + foreach ($questions as $q) { + $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; + $type = $q['type'] ?? ''; + if ($type === 'glass_scale_question') { + $symptoms = $cfg['symptoms'] ?? []; + if (is_array($symptoms) && $symptoms !== []) { + foreach ($symptoms as $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk === '') { + continue; + } + $columns[] = [ + 'header' => $sk, + 'questionID' => $q['questionID'], + 'symptomKey' => $sk, + 'kind' => 'glass_symptom', + 'question' => $q, + ]; + } + continue; + } + } + $columns[] = [ + 'header' => qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID), + 'questionID' => $q['questionID'], + 'symptomKey' => null, + 'kind' => 'question', + 'question' => $q, + ]; + } + return $columns; +} + +/** Cell value for one export/results column (including glass symptom columns). */ +function qdb_results_column_cell_value(array $column, ?array $answerRow, array $optionTextMap): string +{ + if (($column['kind'] ?? '') === 'glass_symptom') { + return qdb_glass_symptom_answer_value( + $answerRow['freeTextValue'] ?? null, + (string)($column['symptomKey'] ?? '') + ); + } + return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap); +} + +/** Format one client_answer row for results table / CSV export. */ +function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string +{ + if (!$answerRow) { + return ''; + } + if (($question['type'] ?? '') === 'glass_scale_question') { + return qdb_format_glass_answer_json($answerRow['freeTextValue'] ?? null); + } + $aoid = $answerRow['answerOptionID'] ?? null; + if ($aoid && isset($optionTextMap[$aoid])) { + return (string)$optionTextMap[$aoid]; + } + if (!empty($answerRow['freeTextValue'])) { + return (string)$answerRow['freeTextValue']; + } + if (isset($answerRow['numericValue']) && $answerRow['numericValue'] !== null && $answerRow['numericValue'] !== '') { + return (string)$answerRow['numericValue']; + } + return ''; +} + +function qdb_note_before_key(string $questionKey): string { + return $questionKey . '_note_before'; +} + +function qdb_note_after_key(string $questionKey): string { + return $questionKey . '_note_after'; +} + +/** Strip deprecated fields and persist questionKey + notes in config. */ +function qdb_normalize_question_config(array $config, string $questionKey, ?string $noteBefore = null, ?string $noteAfter = null): array { + unset($config['multiline']); + $config['questionKey'] = $questionKey; + if ($noteBefore !== null) { + $nb = trim($noteBefore); + if ($nb !== '') { + $config['noteBefore'] = $nb; + } else { + unset($config['noteBefore']); + } + } + if ($noteAfter !== null) { + $na = trim($noteAfter); + if ($na !== '') { + $config['noteAfter'] = $na; + } else { + unset($config['noteAfter']); + } + } + return $config; +} + +function qdb_parse_config_json($configJson): array { + if (is_array($configJson)) { + $config = $configJson; + } else { + $config = json_decode($configJson ?: '{}', true) ?: []; + } + unset($config['multiline']); + return $config; +} + +/** + * @return list + */ +function qdb_parse_glass_symptoms_body(array $body, array $config): array { + if (isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) { + $rows = []; + foreach ($body['glassSymptoms'] as $row) { + if (!is_array($row)) { + continue; + } + $key = trim((string)($row['key'] ?? '')); + if ($key === '') { + continue; + } + $key = qdb_validate_stable_key($key, 'Symptom key'); + $rows[] = [ + 'key' => $key, + 'labelDe' => trim((string)($row['labelDe'] ?? '')), + ]; + } + return $rows; + } + $out = []; + foreach ($config['symptoms'] ?? [] as $symptomKey) { + $key = trim((string)$symptomKey); + if ($key !== '') { + $out[] = ['key' => $key, 'labelDe' => '']; + } + } + return $out; +} + +/** Apply ordered symptom keys from editor rows into question config. */ +function qdb_apply_glass_symptoms_config(array $config, array $glassSymptoms): array { + $keys = []; + foreach ($glassSymptoms as $row) { + $keys[] = $row['key']; + } + if ($keys) { + $config['symptoms'] = $keys; + } else { + unset($config['symptoms']); + } + return $config; +} + +/** Upsert German labels for glass-scale symptom string keys. */ +function qdb_sync_glass_symptom_strings(PDO $pdo, array $glassSymptoms): void { + foreach ($glassSymptoms as $row) { + $key = $row['key'] ?? ''; + $label = trim((string)($row['labelDe'] ?? '')); + if ($key === '' || $label === '') { + continue; + } + qdb_put_translation($pdo, 'string', $key, QDB_SOURCE_LANGUAGE, $label); + } +} + +/** + * Symptom keys with German labels for editor / API consumers. + * + * @return list + */ +function qdb_glass_symptoms_with_labels(PDO $pdo, array $config): array { + $rows = []; + foreach ($config['symptoms'] ?? [] as $symptomKey) { + $key = trim((string)$symptomKey); + if ($key === '') { + continue; + } + $label = qdb_string_german_label($pdo, $key); + if ($label === $key) { + $label = ''; + } + $rows[] = ['key' => $key, 'labelDe' => $label]; + } + return $rows; +} + +/** Sync note German text into string_translation rows for the app. */ +function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void { + if ($questionKey === '') { + return; + } + foreach ([ + 'noteBefore' => qdb_note_before_key($questionKey), + 'noteAfter' => qdb_note_after_key($questionKey), + ] as $field => $stringKey) { + $text = trim((string)($config[$field] ?? '')); + if ($text !== '') { + qdb_put_translation($pdo, 'string', $stringKey, QDB_SOURCE_LANGUAGE, $text); + } + } +} + +function qdb_assert_unique_question_key(PDO $pdo, string $qnID, string $questionKey, ?string $excludeQuestionID = null): void { + $stmt = $pdo->prepare('SELECT questionID, configJson, defaultText FROM question WHERE questionnaireID = :qn'); + $stmt->execute([':qn' => $qnID]); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ($excludeQuestionID !== null && $row['questionID'] === $excludeQuestionID) { + continue; + } + $cfg = json_decode($row['configJson'] ?: '{}', true) ?: []; + $existing = qdb_question_key($cfg, $row['defaultText']); + if ($existing !== '' && $existing === $questionKey) { + json_error('CONFLICT', "Question key \"$questionKey\" already exists in this questionnaire", 409); + } + } +} + +function qdb_assert_unique_option_key(PDO $pdo, string $questionID, string $optionKey, ?string $excludeOptionID = null): void { + $stmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid'); + $stmt->execute([':qid' => $questionID]); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ($excludeOptionID !== null && $row['answerOptionID'] === $excludeOptionID) { + continue; + } + if ($row['defaultText'] === $optionKey) { + json_error('CONFLICT', "Option key \"$optionKey\" already exists on this question", 409); + } + } +} + +/** German label for an option (translation de, else legacy defaultText when not a stable key). */ +function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $storedDefaultText): string { + $tr = qdb_fetch_translation_map($pdo, 'answer_option', $answerOptionID); + $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); + if ($de !== '') { + return $de; + } + if (!qdb_is_stable_key($storedDefaultText)) { + return $storedDefaultText; + } + return ''; +} + +/** German label for a string catalog key (symptoms, glass levels, etc.). */ +function qdb_string_german_label(PDO $pdo, string $stringKey, array &$cache = []): string { + $stringKey = trim($stringKey); + if ($stringKey === '') { + return ''; + } + if (isset($cache[$stringKey])) { + return $cache[$stringKey]; + } + $tr = qdb_fetch_translation_map($pdo, 'string', $stringKey); + $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); + $label = $de !== '' ? $de : $stringKey; + $cache[$stringKey] = $label; + return $label; +} + +/** German question text for UI display. */ +function qdb_question_german_label(PDO $pdo, array $questionRow): string { + $tr = qdb_fetch_translation_map($pdo, 'question', $questionRow['questionID']); + $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); + if ($de !== '') { + return $de; + } + $defaultText = trim($questionRow['defaultText'] ?? ''); + if ($defaultText !== '' && !qdb_is_stable_key($defaultText)) { + return $defaultText; + } + return qdb_question_local_id($questionRow['questionID']); +} + +/** + * Result columns + option map using German labels (client detail, not CSV export). + * + * @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array} + */ +function qdb_display_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); + $stringLabelCache = []; + $resultColumns = []; + + foreach ($questions as $q) { + $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; + $type = $q['type'] ?? ''; + if ($type === 'glass_scale_question') { + $symptoms = $cfg['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' => $q['questionID'], + 'symptomKey' => $sk, + 'kind' => 'glass_symptom', + 'question' => $q, + ]; + } + continue; + } + } + $resultColumns[] = [ + 'header' => qdb_question_german_label($pdo, $q), + 'questionID' => $q['questionID'], + 'symptomKey' => null, + 'kind' => 'question', + 'question' => $q, + ]; + } + + $optionTextMap = []; + foreach ($questions as $q) { + $aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid'); + $aoStmt->execute([':qid' => $q['questionID']]); + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionTextMap[$ao['answerOptionID']] = qdb_option_german_label( + $pdo, + $ao['answerOptionID'], + $ao['defaultText'] + ); + } + } + + return [ + 'questions' => $questions, + 'resultColumns' => $resultColumns, + 'optionTextMap' => $optionTextMap, + 'stringLabelCache' => $stringLabelCache, + ]; +} + +/** Format one answer cell for UI (German labels). */ +function qdb_display_column_cell_value( + PDO $pdo, + array $column, + ?array $answerRow, + array $optionTextMap, + array &$stringLabelCache +): string { + if (($column['kind'] ?? '') === 'glass_symptom') { + $raw = qdb_glass_symptom_answer_value( + $answerRow['freeTextValue'] ?? null, + (string)($column['symptomKey'] ?? '') + ); + if ($raw === '') { + return ''; + } + return qdb_string_german_label($pdo, $raw, $stringLabelCache); + } + if (($column['question']['type'] ?? '') === 'glass_scale_question') { + return qdb_format_glass_answer_json_display($pdo, $answerRow['freeTextValue'] ?? null, $stringLabelCache); + } + return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap); +} + +/** Glass-scale JSON with German symptom keys and level labels. */ +function qdb_format_glass_answer_json_display(PDO $pdo, ?string $raw, array &$stringLabelCache): string +{ + if ($raw === null || trim($raw) === '') { + return ''; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $raw; + } + $parts = []; + foreach ($decoded as $key => $val) { + $val = trim((string)$val); + if ($val === '') { + continue; + } + $symLabel = qdb_string_german_label($pdo, (string)$key, $stringLabelCache); + $levelLabel = qdb_string_german_label($pdo, $val, $stringLabelCache); + $parts[] = $symLabel . ': ' . $levelLabel; + } + return implode('; ', $parts); +} + +/** + * Build translation entry lists for one questionnaire. + * Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved). + */ +function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { + $qStmt = $pdo->prepare(" + SELECT q.questionID, q.defaultText, q.type, q.configJson, q.orderIndex + FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex + "); + $qStmt->execute([':id' => $qnID]); + $dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC); + + $stringKeys = []; + $stringEntries = []; + $contentEntries = []; + $addedSymptomKeys = []; + $appKeySet = qdb_app_string_key_set(); + + foreach ($dbQuestions as $dbQ) { + $qOrder = (int)($dbQ['orderIndex'] ?? 0); + $qLocal = qdb_question_local_id($dbQ['questionID']); + $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; + $qKey = qdb_question_key($config, $dbQ['defaultText']); + $germanQ = trim($dbQ['defaultText']); + if ($qKey !== '' && qdb_is_stable_key($germanQ) && $germanQ === $qKey) { + $germanQ = ''; + } + $contentEntries[] = [ + 'key' => $qKey !== '' ? $qKey : $dbQ['defaultText'], + 'displayKey' => $qKey !== '' ? $qKey : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal), + 'germanText' => $germanQ, + 'type' => 'question', + 'entityId' => $dbQ['questionID'], + 'sortOrder' => $qOrder * 1000, + ]; + + $aoStmt = $pdo->prepare(" + SELECT answerOptionID, defaultText, orderIndex + FROM answer_option WHERE questionID = :qid ORDER BY orderIndex + "); + $aoStmt->execute([':qid' => $dbQ['questionID']]); + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optKey = qdb_option_key($ao); + $optLabel = $optKey !== '' + ? ($qLocal . ' · ' . $optKey) + : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) + ? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) + : ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1))); + $contentEntries[] = [ + 'key' => $optKey !== '' ? $optKey : $ao['defaultText'], + 'displayKey' => $optLabel, + 'germanText' => qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']), + 'type' => 'answer_option', + 'entityId' => $ao['answerOptionID'], + 'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), + ]; + } + + foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) { + if (!empty($config[$field])) { + $stringKeys[$config[$field]] = true; + } + } + if (($dbQ['type'] ?? '') === 'glass_scale_question' + && !empty($config['symptoms']) && is_array($config['symptoms'])) { + $symIdx = 0; + foreach ($config['symptoms'] as $s) { + $sk = trim((string)$s); + if ($sk === '' || isset($appKeySet[$sk]) || isset($addedSymptomKeys[$sk])) { + continue; + } + $addedSymptomKeys[$sk] = true; + $parentLabel = $qKey !== '' ? $qKey : $qLocal; + $stringEntries[] = [ + 'key' => $sk, + 'displayKey' => $qLocal . ' · ' . $parentLabel . ' · ' . $sk, + 'type' => 'string', + 'entityId' => $sk, + 'sortOrder' => $qOrder * 1000 + 5 + $symIdx, + ]; + $symIdx++; + } + } + if ($qKey !== '') { + if (!empty($config['noteBefore'])) { + $stringKeys[qdb_note_before_key($qKey)] = true; + } + if (!empty($config['noteAfter'])) { + $stringKeys[qdb_note_after_key($qKey)] = true; + } + } + } + + $stringOrder = 0; + $skList = array_keys($stringKeys); + sort($skList, SORT_STRING); + foreach ($skList as $sk) { + if (isset($appKeySet[$sk])) { + continue; + } + $stringEntries[] = [ + 'key' => $sk, + 'displayKey' => $sk, + 'type' => 'string', + 'entityId' => $sk, + 'sortOrder' => $stringOrder++, + ]; + } + + return ['stringEntries' => $stringEntries, 'contentEntries' => $contentEntries]; +} + +/** Attach translation texts and germanText to entry rows. */ +function qdb_attach_translation_texts(array &$entries, array $translations): void { + foreach ($entries as &$e) { + $tr = $translations[$e['entityId']] ?? []; + $e['translations'] = (object)$tr; + $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); + if ($de !== '') { + $e['germanText'] = $de; + } elseif (!empty($e['germanDefault'])) { + $e['germanText'] = $e['germanDefault']; + } else { + $e['germanText'] = $e['key']; + } + unset($e['germanDefault']); + } + unset($e); +} + +/** Load all translation rows for a flat entry list. */ +function qdb_load_translations_for_entries(PDO $pdo, array $entries): array { + $translations = []; + + $qIds = array_values(array_filter(array_map( + fn($e) => $e['type'] === 'question' ? $e['entityId'] : null, + $entries + ))); + if ($qIds) { + $ph = implode(',', array_fill(0, count($qIds), '?')); + $stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)"); + $stmt->execute($qIds); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { + $translations[$r['entityId']][$r['languageCode']] = $r['text']; + } + } + + $aoIds = array_values(array_filter(array_map( + fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null, + $entries + ))); + if ($aoIds) { + $ph = implode(',', array_fill(0, count($aoIds), '?')); + $stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)"); + $stmt->execute($aoIds); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { + $translations[$r['entityId']][$r['languageCode']] = $r['text']; + } + } + + $sKeys = array_values(array_filter(array_map( + fn($e) => ($e['type'] === 'string' || $e['type'] === 'app_string') ? $e['entityId'] : null, + $entries + ))); + if ($sKeys) { + $ph = implode(',', array_fill(0, count($sKeys), '?')); + $stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)"); + $stmt->execute($sKeys); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { + $translations[$r['entityId']][$r['languageCode']] = $r['text']; + } + } + + return $translations; +} + +/** + * Flat language -> stringKey -> text map for global app UI strings only. + * @return array> + */ +function qdb_build_app_translations_map(PDO $pdo): array { + $entries = qdb_app_ui_string_entries_merged($pdo); + $translations = qdb_load_translations_for_entries($pdo, $entries); + $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; + $result = []; + foreach ($entries as $e) { + $key = $e['key']; + foreach ($translations[$e['entityId']] ?? [] as $lang => $text) { + $result[$lang][$key] = $text; + } + if (!isset($result[QDB_SOURCE_LANGUAGE][$key]) && !empty($defaults[$key])) { + $result[QDB_SOURCE_LANGUAGE][$key] = $defaults[$key]; + } + } + return $result; +} + +/** + * Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings). + * Excludes keys from the global app UI catalog. + * @return array> + */ +function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array { + $lists = qdb_translation_entry_lists($pdo, $qnID); + $entries = array_merge($lists['stringEntries'], $lists['contentEntries']); + if (!$entries) { + return []; + } + $translations = qdb_load_translations_for_entries($pdo, $entries); + $result = []; + foreach ($entries as $e) { + $key = $e['key']; + foreach ($translations[$e['entityId']] ?? [] as $lang => $text) { + $result[$lang][$key] = $text; + } + } + return $result; +} + +/** @return array */ +function qdb_translation_rows_to_map(array $rows): array { + $map = []; + foreach ($rows as $row) { + if (is_array($row) && isset($row['languageCode'])) { + $map[$row['languageCode']] = $row['text'] ?? ''; + } + } + return $map; +} + +/** @return array */ +function qdb_fetch_translation_map(PDO $pdo, string $type, string $entityId): array { + if ($type === 'question') { + $stmt = $pdo->prepare('SELECT languageCode, text FROM question_translation WHERE questionID = :id'); + } elseif ($type === 'answer_option') { + $stmt = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); + } else { + $stmt = $pdo->prepare('SELECT languageCode, text FROM string_translation WHERE stringKey = :id'); + } + $stmt->execute([':id' => $entityId]); + return qdb_translation_rows_to_map($stmt->fetchAll(PDO::FETCH_ASSOC)); +} + +function qdb_apply_translation_map(PDO $pdo, string $type, string $entityId, array $map): void { + foreach ($map as $lang => $text) { + if (!is_string($lang) || $lang === '') { + continue; + } + qdb_put_translation($pdo, $type, $entityId, $lang, (string)$text); + } +} + +function qdb_question_local_id(string $questionID, ?string $questionnaireID = null): string { + $pos = strrpos($questionID, '__'); + return $pos !== false ? substr($questionID, $pos + 2) : $questionID; +} + +/** Column / export header: stable question key, else local id (q1, q2, …). */ +function qdb_question_column_label(array $config, string $defaultText, string $questionID, ?string $questionnaireID = null): string { + $key = qdb_question_key($config, $defaultText); + return $key !== '' ? $key : qdb_question_local_id($questionID, $questionnaireID); +} + +function qdb_make_question_id(string $questionnaireID, string $localId): string { + return $questionnaireID . '__' . $localId; +} + +/** Next short id for a new question (q1, q2, …) within one questionnaire. */ +function qdb_allocate_question_local_id(PDO $pdo, string $questionnaireID): string { + $stmt = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); + $stmt->execute([':id' => $questionnaireID]); + $max = 0; + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $questionID) { + $local = qdb_question_local_id($questionID); + if (preg_match('/^q(\d+)$/i', $local, $m)) { + $max = max($max, (int)$m[1]); + } + } + return 'q' . ($max + 1); +} + +/** Human-readable Key column label (not the app translation lookup key). */ +function qdb_translation_row_label(array $e): string { + if (!empty($e['displayKey']) && !preg_match('/^[a-f0-9]{32}$/i', $e['displayKey'])) { + return $e['displayKey']; + } + $type = $e['type'] ?? ''; + $key = $e['key'] ?? ''; + if ($type === 'question' || $type === 'answer_option') { + if ($key !== '') { + $short = mb_strlen($key) > 56 ? mb_substr($key, 0, 56) . '…' : $key; + return $short; + } + } + $id = $e['entityId'] ?? ''; + $pos = strrpos($id, '__'); + if ($pos !== false) { + return substr($id, $pos + 2); + } + return $key !== '' ? $key : $id; +} + +/** Export one questionnaire with structure + all translations (portable JSON). */ +function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { + require_once __DIR__ . '/lib/scoring.php'; + 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) { + return null; + } + + $qStmt = $pdo->prepare( + 'SELECT questionID, defaultText, type, orderIndex, isRequired, configJson + FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . ' + ORDER BY orderIndex' + ); + $qStmt->execute([':id' => $qnID]); + $questionsOut = []; + + foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $dbQ) { + $localId = qdb_question_local_id($dbQ['questionID'], $qnID); + $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; + + $optionsOut = []; + $aoStmt = $pdo->prepare( + 'SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId + FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . ' + ORDER BY orderIndex' + ); + $aoStmt->execute([':qid' => $dbQ['questionID']]); + $qKey = qdb_question_key($config, $dbQ['defaultText']); + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optKey = qdb_option_key($ao); + $optTr = qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']); + $optGerman = trim($optTr[QDB_SOURCE_LANGUAGE] ?? ''); + if ($optGerman === '' && !qdb_is_stable_key($ao['defaultText'])) { + $optGerman = $ao['defaultText']; + } + $optionsOut[] = [ + 'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'], + 'defaultText' => $optGerman, + 'points' => (int)$ao['points'], + 'orderIndex' => (int)$ao['orderIndex'], + 'nextQuestionId' => $ao['nextQuestionId'] ?? '', + 'translations' => $optTr, + ]; + } + + $questionsOut[] = [ + 'localId' => $localId, + 'questionKey' => $qKey, + 'defaultText' => $dbQ['defaultText'], + 'type' => $dbQ['type'], + 'orderIndex' => (int)$dbQ['orderIndex'], + 'isRequired' => (int)$dbQ['isRequired'], + 'config' => $config, + 'answerOptions' => $optionsOut, + 'scoreRules' => qdb_get_score_rules_for_question($pdo, $dbQ['questionID']), + 'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']), + ]; + } + + $lists = qdb_translation_entry_lists($pdo, $qnID); + $stringTranslations = []; + if (!empty($lists['stringEntries'])) { + $tr = qdb_load_translations_for_entries($pdo, $lists['stringEntries']); + foreach ($lists['stringEntries'] as $e) { + $stringTranslations[] = [ + 'stringKey' => $e['key'], + 'translations' => $tr[$e['entityId']] ?? [], + ]; + } + } + + return [ + 'questionnaire' => [ + 'questionnaireID' => $qnRow['questionnaireID'], + 'name' => $qnRow['name'], + 'version' => $qnRow['version'], + 'state' => $qnRow['state'], + 'orderIndex' => (int)$qnRow['orderIndex'], + 'showPoints' => (int)$qnRow['showPoints'], + 'conditionJson' => $qnRow['conditionJson'] ?: '{}', + 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID), + ], + 'questions' => $questionsOut, + 'stringTranslations' => $stringTranslations, + 'translationsFlat' => qdb_build_questionnaire_translations_map($pdo, $qnID), + ]; +} + +/** Export every questionnaire as a portable bundle. */ +function qdb_export_all_questionnaires_bundle(PDO $pdo): array { + $ids = $pdo->query('SELECT questionnaireID FROM questionnaire ORDER BY orderIndex, name') + ->fetchAll(PDO::FETCH_COLUMN); + $items = []; + foreach ($ids as $id) { + $item = qdb_export_questionnaire_bundle($pdo, $id); + if ($item !== null) { + $items[] = $item; + } + } + return [ + 'exportVersion' => 2, + 'exportedAt' => time(), + 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'questionnaireCount' => count($items), + 'questionnaires' => $items, + 'scoringProfiles' => qdb_export_scoring_profiles_bundle($pdo), + ]; +} + +/** Export all scoring profiles for bundle portability. */ +function qdb_export_scoring_profiles_bundle(PDO $pdo): array { + require_once __DIR__ . '/lib/scoring.php'; + return qdb_list_scoring_profiles($pdo); +} + +/** Remove questionnaire and related rows (full replace import; not per-question retire). */ +function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void { + $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); + $qIds->execute([':id' => $qnID]); + $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]); + 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 = :qid')->execute([':qid' => $qid]); + $pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]); + if (qdb_table_exists($pdo, 'client_answer_submission')) { + $pdo->prepare('DELETE FROM client_answer_submission WHERE questionID = :qid') + ->execute([':qid' => $qid]); + } + $pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]); + } + if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) { + $pdo->prepare('DELETE FROM questionnaire_structure_snapshot WHERE questionnaireID = :id') + ->execute([':id' => $qnID]); + } + if (qdb_table_exists($pdo, 'questionnaire_submission')) { + $pdo->prepare('DELETE FROM questionnaire_submission WHERE questionnaireID = :id') + ->execute([':id' => $qnID]); + } + $pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]); + $pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); + $pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); +} + +/** + * Import one questionnaire from a bundle item. + * @return array{questionnaireID: string, created: bool, replaced: bool} + */ +function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfExists = false): array { + $meta = $item['questionnaire'] ?? []; + if (empty($meta['name'])) { + json_error('MISSING_FIELDS', 'questionnaire.name is required', 400); + } + + $wantedId = trim($meta['questionnaireID'] ?? ''); + $chk = $wantedId !== '' ? $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id') : null; + if ($chk) { + $chk->execute([':id' => $wantedId]); + } + $exists = $chk && (bool)$chk->fetch(); + + $replaced = false; + if ($exists && $replaceIfExists) { + qdb_delete_questionnaire_cascade($pdo, $wantedId); + $replaced = true; + $qnID = $wantedId; + } elseif ($exists) { + $qnID = bin2hex(random_bytes(16)); + } elseif ($wantedId !== '') { + $qnID = $wantedId; + } else { + $qnID = bin2hex(random_bytes(16)); + } + + $order = (int)($meta['orderIndex'] ?? 0); + if ($order === 0) { + $order = (int)$pdo->query('SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire')->fetchColumn(); + } + + $condJson = $meta['conditionJson'] ?? '{}'; + if (is_array($condJson)) { + $condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE); + } + + $catKey = trim($meta['categoryKey'] ?? ''); + $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) + VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)") + ->execute([ + ':id' => $qnID, + ':n' => trim($meta['name']), + ':v' => trim($meta['version'] ?? ''), + ':s' => trim($meta['state'] ?? 'draft'), + ':o' => $order, + ':sp' => (int)($meta['showPoints'] ?? 0), + ':cj' => $condJson ?: '{}', + ':ck' => $catKey, + ]); + + foreach ($item['questions'] ?? [] as $q) { + $localId = trim($q['localId'] ?? ''); + if ($localId === '') { + $localId = 'q_' . bin2hex(random_bytes(4)); + } + $questionID = $qnID . '__' . $localId; + + $config = qdb_parse_config_json($q['config'] ?? $q['configJson'] ?? []); + $questionKey = trim((string)($q['questionKey'] ?? $config['questionKey'] ?? '')); + $text = trim($q['defaultText'] ?? ''); + $qTr = $q['translations'] ?? []; + if (is_array($qTr) && isset($qTr[0]['languageCode'])) { + $qTr = qdb_translation_rows_to_map($qTr); + } + if ($questionKey === '' && qdb_is_stable_key($text) && trim($qTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $qTr[QDB_SOURCE_LANGUAGE] !== $text) { + $questionKey = $text; + $text = trim($qTr[QDB_SOURCE_LANGUAGE]); + } + if ($questionKey === '') { + json_error( + 'MISSING_FIELDS', + "Question key is required for {$qnID} / {$localId} (stable key [a-z][a-z0-9_]*)", + 400 + ); + } + $questionKey = qdb_validate_stable_key($questionKey, 'Question key'); + // Duplicate question keys in one questionnaire are allowed (same prompt in different branches). + + qdb_require_non_empty_german($text, 'Question German text'); + $noteBefore = $config['noteBefore'] ?? null; + $noteAfter = $config['noteAfter'] ?? null; + $config = qdb_normalize_question_config($config, $questionKey, $noteBefore, $noteAfter); + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); + + $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) + VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") + ->execute([ + ':id' => $questionID, + ':qid' => $qnID, + ':t' => $text, + ':ty' => trim($q['type'] ?? ''), + ':o' => (int)($q['orderIndex'] ?? 0), + ':r' => (int)($q['isRequired'] ?? 0), + ':cj' => $configJson, + ]); + + qdb_upsert_source_translation($pdo, 'question', $questionID, $text); + qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); + qdb_sync_question_note_strings($pdo, $questionKey, $config); + + if (!empty($q['scoreRules']) && is_array($q['scoreRules'])) { + require_once __DIR__ . '/lib/scoring.php'; + qdb_sync_question_score_rules($pdo, $questionID, $q['scoreRules']); + } + + foreach ($q['answerOptions'] ?? [] as $opt) { + $optKey = trim((string)($opt['optionKey'] ?? '')); + $optGerman = trim($opt['defaultText'] ?? ''); + $oTr = $opt['translations'] ?? []; + if (is_array($oTr) && isset($oTr[0]['languageCode'])) { + $oTr = qdb_translation_rows_to_map($oTr); + } + if ($optKey === '' && qdb_is_stable_key($optGerman) && trim($oTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $oTr[QDB_SOURCE_LANGUAGE] !== $optGerman) { + $optKey = $optGerman; + $optGerman = trim($oTr[QDB_SOURCE_LANGUAGE]); + } + if ($optKey === '' && qdb_is_stable_key($optGerman)) { + $optKey = $optGerman; + $optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? $optGerman); + } + if ($optKey === '') { + json_error( + 'MISSING_FIELDS', + "Option key is required for {$qnID} / {$localId} option #" . ((int)($opt['orderIndex'] ?? 0) + 1), + 400 + ); + } + $optKey = qdb_validate_stable_key($optKey, 'Option key'); + if ($optGerman === '') { + $optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? ''); + } + qdb_require_non_empty_german($optGerman, 'Answer option German text'); + + $aoId = bin2hex(random_bytes(16)); + $pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) + VALUES (:id, :qid, :t, :p, :o, :nq)") + ->execute([ + ':id' => $aoId, + ':qid' => $questionID, + ':t' => $optKey, + ':p' => (int)($opt['points'] ?? 0), + ':o' => (int)($opt['orderIndex'] ?? 0), + ':nq' => trim($opt['nextQuestionId'] ?? ''), + ]); + qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman); + qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []); + } + } + + foreach ($item['stringTranslations'] ?? [] as $st) { + $sk = trim($st['stringKey'] ?? ''); + if ($sk === '') { + continue; + } + $map = $st['translations'] ?? []; + if (is_array($map) && isset($map[0]['languageCode'])) { + $map = qdb_translation_rows_to_map($map); + } + qdb_apply_translation_map($pdo, 'string', $sk, is_array($map) ? $map : []); + } + + return [ + 'questionnaireID' => $qnID, + 'created' => true, + 'replaced' => $replaced, + ]; +} + +/** One translation row for a portable translations JSON bundle. */ +function qdb_translation_entry_to_bundle_row(array $e, ?string $questionnaireID = null, ?string $questionnaireName = null): array { + $tr = $e['translations'] ?? []; + if ($tr instanceof stdClass) { + $tr = (array)$tr; + } + if (is_array($tr) && isset($tr[0]) && is_array($tr[0]) && isset($tr[0]['languageCode'])) { + $tr = qdb_translation_rows_to_map($tr); + } + $row = [ + 'type' => $e['type'], + 'entityId' => $e['entityId'], + 'key' => $e['key'] ?? '', + 'translations' => is_array($tr) ? $tr : [], + ]; + if (!empty($e['displayKey'])) { + $row['displayKey'] = $e['displayKey']; + } + if ($questionnaireID !== null && $questionnaireID !== '') { + $row['questionnaireID'] = $questionnaireID; + } + if ($questionnaireName !== null && $questionnaireName !== '') { + $row['questionnaireName'] = $questionnaireName; + } + return $row; +} + +/** @return array */ +function qdb_normalize_bundle_translations($raw): array { + if (!is_array($raw)) { + return []; + } + if (isset($raw[0]) && is_array($raw[0]) && isset($raw[0]['languageCode'])) { + return qdb_translation_rows_to_map($raw); + } + $map = []; + foreach ($raw as $lang => $text) { + if (is_string($lang) && $lang !== '') { + $map[$lang] = (string)$text; + } + } + return $map; +} + +function qdb_translation_entity_exists(PDO $pdo, string $type, string $entityId): bool { + if ($type === 'string' || $type === 'app_string') { + return true; + } + if ($type === 'question') { + $stmt = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id LIMIT 1'); + } elseif ($type === 'answer_option') { + $stmt = $pdo->prepare('SELECT 1 FROM answer_option WHERE answerOptionID = :id LIMIT 1'); + } else { + return false; + } + $stmt->execute([':id' => $entityId]); + return (bool)$stmt->fetchColumn(); +} + +/** Export all translations (languages + flat entries) as portable JSON. */ +function qdb_export_translations_bundle(PDO $pdo): array { + $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']); + } + qdb_ensure_source_language($pdo); + + $exportEntries = []; + $appEntries = qdb_app_ui_string_entries_for_website($pdo); + $appTranslations = qdb_load_translations_for_entries($pdo, $appEntries); + qdb_attach_translation_texts($appEntries, $appTranslations); + foreach ($appEntries as $e) { + $exportEntries[] = qdb_translation_entry_to_bundle_row($e); + } + + $qnRows = $pdo->query('SELECT questionnaireID, name FROM questionnaire ORDER BY orderIndex, name') + ->fetchAll(PDO::FETCH_ASSOC); + 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); + foreach ($entries as $e) { + $exportEntries[] = qdb_translation_entry_to_bundle_row( + $e, + $qn['questionnaireID'], + $qn['name'] + ); + } + } + + return [ + 'exportVersion' => 1, + 'exportedAt' => time(), + 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'languageCount' => count($languages), + 'languages' => $languages, + 'entryCount' => count($exportEntries), + 'entries' => $exportEntries, + ]; +} + +/** Import translations bundle (upsert languages and translation rows). */ +function qdb_import_translations_bundle(PDO $pdo, array $bundle): array { + $entries = $bundle['entries'] ?? null; + if (!is_array($entries)) { + json_error('MISSING_FIELDS', 'bundle.entries array is required', 400); + } + + qdb_ensure_source_language($pdo); + foreach ($bundle['languages'] ?? [] as $langRow) { + if (!is_array($langRow)) { + continue; + } + $lc = trim($langRow['languageCode'] ?? ''); + $name = trim($langRow['name'] ?? ''); + if ($lc === '' || $lc === QDB_SOURCE_LANGUAGE) { + continue; + } + $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) + ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name') + ->execute([':lc' => $lc, ':n' => $name]); + } + + $validTypes = ['question', 'answer_option', 'string', 'app_string']; + $imported = 0; + $skipped = 0; + + foreach ($entries as $entry) { + if (!is_array($entry)) { + $skipped++; + continue; + } + $type = $entry['type'] ?? ''; + $entityId = trim((string)($entry['entityId'] ?? '')); + if (!in_array($type, $validTypes, true) || $entityId === '') { + $skipped++; + continue; + } + if (!qdb_translation_entity_exists($pdo, $type, $entityId)) { + $skipped++; + continue; + } + $map = qdb_normalize_bundle_translations($entry['translations'] ?? []); + if ($map) { + qdb_apply_translation_map($pdo, $type, $entityId, $map); + $imported++; + } else { + $skipped++; + } + } + + return [ + 'imported' => $imported, + 'skipped' => $skipped, + 'total' => count($entries), + ]; +} + +/** Import a full export bundle. */ +function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array { + $items = $bundle['questionnaires'] ?? []; + if (!is_array($items) || !$items) { + json_error('MISSING_FIELDS', 'bundle.questionnaires array is required', 400); + } + foreach ($bundle['appStringTranslations'] ?? [] as $st) { + if (!is_array($st)) { + continue; + } + $sk = trim($st['stringKey'] ?? ''); + if ($sk === '') { + continue; + } + $map = qdb_normalize_bundle_translations($st['translations'] ?? []); + if (!$map) { + continue; + } + if (isset($map[QDB_SOURCE_LANGUAGE])) { + qdb_set_app_string_german_label($pdo, $sk, $map[QDB_SOURCE_LANGUAGE]); + unset($map[QDB_SOURCE_LANGUAGE]); + } + if ($map) { + qdb_apply_translation_map($pdo, 'app_string', $sk, $map); + } + } + $results = []; + foreach ($items as $item) { + $results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists); + } + if (!empty($bundle['scoringProfiles']) && is_array($bundle['scoringProfiles'])) { + qdb_import_scoring_profiles_bundle($pdo, $bundle['scoringProfiles'], $replaceIfExists); + } + return [ + 'imported' => count($results), + 'results' => $results, + ]; +} + +/** Import scoring profiles from a questionnaire bundle. */ +function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $replaceIfExists = false): void { + require_once __DIR__ . '/lib/scoring.php'; + foreach ($profiles as $profile) { + if (!is_array($profile)) { + continue; + } + $wantedId = trim($profile['profileID'] ?? ''); + $name = trim($profile['name'] ?? ''); + if ($name === '') { + continue; + } + $exists = false; + if ($wantedId !== '') { + $chk = $pdo->prepare('SELECT 1 FROM scoring_profile WHERE profileID = :id'); + $chk->execute([':id' => $wantedId]); + $exists = (bool)$chk->fetch(); + } + if ($exists && $replaceIfExists) { + $pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $wantedId]); + $profileID = $wantedId; + } elseif ($exists) { + $profileID = bin2hex(random_bytes(16)); + } else { + $profileID = $wantedId !== '' ? $wantedId : 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($profile['description'] ?? ''), + ':act' => !empty($profile['isActive']) ? 1 : 0, + ':gmin' => (int)($profile['greenMin'] ?? 0), + ':gmax' => (int)($profile['greenMax'] ?? 12), + ':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)), + ':ymax' => (int)($profile['yellowMax'] ?? 36), + ':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)), + ':ca' => (int)($profile['createdAt'] ?? $now), + ':ua' => (int)($profile['updatedAt'] ?? $now), + ]); + $members = []; + foreach ($profile['questionnaires'] ?? [] as $idx => $row) { + if (!is_array($row)) { + continue; + } + $qnID = trim((string)($row['questionnaireID'] ?? '')); + if ($qnID === '') { + continue; + } + $chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id'); + $chk->execute([':id' => $qnID]); + if (!$chk->fetch()) { + continue; + } + $members[] = [ + 'questionnaireID' => $qnID, + 'weight' => (float)($row['weight'] ?? 1.0), + 'orderIndex' => (int)($row['orderIndex'] ?? $idx), + ]; + } + if ($members !== []) { + qdb_sync_profile_questionnaire_members($pdo, $profileID, $members); + } + } +} + +function qdb_sync_profile_questionnaire_members(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'], + ]); + } +} + +// --- AES-256-CBC: IV(16) + CIPHERTEXT --- +function aes256_cbc_encrypt_bytes(string $plain, string $key): string { + $key = str_pad(substr($key, 0, 32), 32, "\0"); + $iv = random_bytes(16); + $cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($cipher === false) throw new Exception('openssl_encrypt failed'); + return $iv . $cipher; +} +function aes256_cbc_decrypt_bytes(string $data, string $key): string { + if (strlen($data) < 16) throw new Exception('cipher too short'); + $key = str_pad(substr($key, 0, 32), 32, "\0"); + $iv = substr($data, 0, 16); + $ct = substr($data, 16); + $plain = openssl_decrypt($ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($plain === false) throw new Exception('openssl_decrypt failed'); + return $plain; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ed7c69c --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/composer.phar b/composer.phar new file mode 100644 index 0000000..fe03d9b Binary files /dev/null and b/composer.phar differ diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json new file mode 100644 index 0000000..d0043f6 --- /dev/null +++ b/data/app_ui_strings.json @@ -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. 8–12) oder einzelne Jahre (z. B. 6, 9, 13) eingeben", + "questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie", + "questionnaire_unlock_3_integration_index": "Verfügbar nach Abschluss von Demografie und RHS", + "questionnaire_unlock_4_consultation_results": "Verfügbar nach Abschluss von Demografie (Einwilligung unterschrieben), RHS und IPL", + "locked": "Gesperrt", + "questionnaire_chip_edit": "Bearbeiten", + "questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend", + "login_btn": "Login", + "login_failed_with_reason": "Login fehlgeschlagen: {reason}", + "login_required": "Bitte zuerst einloggen", + "logout": "Abmelden", + "logout_confirm_action": "Abmelden", + "logout_confirm_message": "Alle Daten auf diesem Gerät werden gelöscht. Sie müssen sich danach erneut anmelden.", + "logout_confirm_title": "Wirklich abmelden?", + "logout_offline_message": "Sie sind offline. Stellen Sie eine Internetverbindung her, laden Sie ausstehende Daten hoch und versuchen Sie es dann erneut.", + "logout_offline_title": "Abmelden nicht möglich", + "logout_pending_detail_in_progress": "{count} begonnene(r) Fragebogen mit lokalen Antworten", + "logout_pending_detail_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" + } +} diff --git a/data/countries_de_sorted.txt b/data/countries_de_sorted.txt new file mode 100644 index 0000000..9c78521 --- /dev/null +++ b/data/countries_de_sorted.txt @@ -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 diff --git a/db_init.php b/db_init.php new file mode 100644 index 0000000..9b0d95e --- /dev/null +++ b/db_init.php @@ -0,0 +1,568 @@ +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'); + } +} diff --git a/handlers/activity_log.php b/handlers/activity_log.php new file mode 100644 index 0000000..8288664 --- /dev/null +++ b/handlers/activity_log.php @@ -0,0 +1,21 @@ + $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); +} diff --git a/handlers/answer_options.php b/handlers/answer_options.php new file mode 100644 index 0000000..0065a9c --- /dev/null +++ b/handlers/answer_options.php @@ -0,0 +1,217 @@ +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); +} diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php new file mode 100644 index 0000000..afa655f --- /dev/null +++ b/handlers/app_questionnaires.php @@ -0,0 +1,626 @@ + ordered questionnaire list with conditions + * GET ?id= -> 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); diff --git a/handlers/assignments.php b/handlers/assignments.php new file mode 100644 index 0000000..3e8696c --- /dev/null +++ b/handlers/assignments.php @@ -0,0 +1,99 @@ +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); +} diff --git a/handlers/auth.php b/handlers/auth.php new file mode 100644 index 0000000..69001b1 --- /dev/null +++ b/handlers/auth.php @@ -0,0 +1,286 @@ +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); +} diff --git a/handlers/backup.php b/handlers/backup.php new file mode 100644 index 0000000..a8a8e3d --- /dev/null +++ b/handlers/backup.php @@ -0,0 +1,33 @@ + $filename]); diff --git a/handlers/clients.php b/handlers/clients.php new file mode 100644 index 0000000..594e0d4 --- /dev/null +++ b/handlers/clients.php @@ -0,0 +1,239 @@ +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); +} diff --git a/handlers/coaches.php b/handlers/coaches.php new file mode 100644 index 0000000..9f593f1 --- /dev/null +++ b/handlers/coaches.php @@ -0,0 +1,29 @@ + $coaches]); +} catch (Throwable $e) { + qdb_handler_fail($e, 'Load coaches', null, $tmpDb ?? null, $lockFp ?? null); +} diff --git a/handlers/dev.php b/handlers/dev.php new file mode 100644 index 0000000..f544713 --- /dev/null +++ b/handlers/dev.php @@ -0,0 +1,53 @@ + $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); +} diff --git a/handlers/export.php b/handlers/export.php new file mode 100644 index 0000000..39d6110 --- /dev/null +++ b/handlers/export.php @@ -0,0 +1,111 @@ + '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); +} diff --git a/handlers/logout.php b/handlers/logout.php new file mode 100644 index 0000000..3dd0aa6 --- /dev/null +++ b/handlers/logout.php @@ -0,0 +1,18 @@ + true]); diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php new file mode 100644 index 0000000..3a172a9 --- /dev/null +++ b/handlers/questionnaires.php @@ -0,0 +1,244 @@ +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); +} diff --git a/handlers/questions.php b/handlers/questions.php new file mode 100644 index 0000000..8bebf9d --- /dev/null +++ b/handlers/questions.php @@ -0,0 +1,357 @@ +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); +} diff --git a/handlers/results.php b/handlers/results.php new file mode 100644 index 0000000..234c97d --- /dev/null +++ b/handlers/results.php @@ -0,0 +1,124 @@ +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); +} diff --git a/handlers/scoring_profiles.php b/handlers/scoring_profiles.php new file mode 100644 index 0000000..f15c37d --- /dev/null +++ b/handlers/scoring_profiles.php @@ -0,0 +1,203 @@ + $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 + */ +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); + } +} diff --git a/handlers/session.php b/handlers/session.php new file mode 100644 index 0000000..226a054 --- /dev/null +++ b/handlers/session.php @@ -0,0 +1,44 @@ +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']), +]); diff --git a/handlers/settings.php b/handlers/settings.php new file mode 100644 index 0000000..cc1433b --- /dev/null +++ b/handlers/settings.php @@ -0,0 +1,70 @@ +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); +} diff --git a/handlers/translations.php b/handlers/translations.php new file mode 100644 index 0000000..44d109d --- /dev/null +++ b/handlers/translations.php @@ -0,0 +1,302 @@ + '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); +} diff --git a/handlers/users.php b/handlers/users.php new file mode 100644 index 0000000..bc1b69c --- /dev/null +++ b/handlers/users.php @@ -0,0 +1,427 @@ +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); +} diff --git a/lib/analytics.php b/lib/analytics.php new file mode 100644 index 0000000..1e69341 --- /dev/null +++ b/lib/analytics.php @@ -0,0 +1,401 @@ + + */ +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> + */ +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, + ]; +} diff --git a/lib/api_log.php b/lib/api_log.php new file mode 100644 index 0000000..4224273 --- /dev/null +++ b/lib/api_log.php @@ -0,0 +1,998 @@ + */ +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 + */ +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 $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|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 + */ +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 $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>, files: list, 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 */ +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 $row @return array */ +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> $entries + * @return list> + */ +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 $userIds @return array */ +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 $supervisorIds @return array */ +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 $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 $userMap + * @param array $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 $changes + * @return list + */ +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 $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; +} diff --git a/lib/app_answers.php b/lib/app_answers.php new file mode 100644 index 0000000..8c85783 --- /dev/null +++ b/lib/app_answers.php @@ -0,0 +1,306 @@ + */ +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 $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 $shortIdToType question localId => layout type + * @return list> + */ +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>, 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> + */ +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>}> + */ +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; +} diff --git a/lib/app_submit_validate.php b/lib/app_submit_validate.php new file mode 100644 index 0000000..84d46d5 --- /dev/null +++ b/lib/app_submit_validate.php @@ -0,0 +1,221 @@ +|null $manifest Structure manifest for legacy revision submits + * @return list + */ +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 + */ +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; +} diff --git a/lib/dev_fixture.php b/lib/dev_fixture.php new file mode 100644 index 0000000..7b2ac5b --- /dev/null +++ b/lib/dev_fixture.php @@ -0,0 +1,911 @@ + 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 + */ +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 + */ +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 $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 $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} + */ +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, kept: array} + */ +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], + ]; +} diff --git a/lib/encrypted_payload.php b/lib/encrypted_payload.php new file mode 100644 index 0000000..f007235 --- /dev/null +++ b/lib/encrypted_payload.php @@ -0,0 +1,60 @@ + 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; +} diff --git a/lib/errors.php b/lib/errors.php new file mode 100644 index 0000000..022e5e5 --- /dev/null +++ b/lib/errors.php @@ -0,0 +1,89 @@ +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); +} diff --git a/lib/keycloak_auth.php b/lib/keycloak_auth.php new file mode 100644 index 0000000..276aeb8 --- /dev/null +++ b/lib/keycloak_auth.php @@ -0,0 +1,221 @@ + $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( + "", + ['Content-Type' => 'text/html; charset=UTF-8'] + ); +} + diff --git a/lib/login_rate_limit.php b/lib/login_rate_limit.php new file mode 100644 index 0000000..6533e56 --- /dev/null +++ b/lib/login_rate_limit.php @@ -0,0 +1,176 @@ + [], '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 + ); +} diff --git a/lib/questionnaire_structure.php b/lib/questionnaire_structure.php new file mode 100644 index 0000000..a4e6e18 --- /dev/null +++ b/lib/questionnaire_structure.php @@ -0,0 +1,414 @@ +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 + */ +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|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, + * shortIdToType: array, + * optionMap: array>, + * symptomParentMap: array, + * freeTextMaxLen: array, + * manifestQuestionIds: list + * } + */ +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 + */ +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 + */ +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; +} diff --git a/lib/read_db_cache.php b/lib/read_db_cache.php new file mode 100644 index 0000000..6f58a99 --- /dev/null +++ b/lib/read_db_cache.php @@ -0,0 +1,299 @@ +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; +} diff --git a/lib/response.php b/lib/response.php new file mode 100644 index 0000000..c6d0ffe --- /dev/null +++ b/lib/response.php @@ -0,0 +1,122 @@ + 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 $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); +} diff --git a/lib/scoring.php b/lib/scoring.php new file mode 100644 index 0000000..9923f12 --- /dev/null +++ b/lib/scoring.php @@ -0,0 +1,784 @@ + 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 + */ +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 $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> + */ +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}> + */ +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 + */ +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}> $glassSymptoms + * @return list + */ +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|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 + */ +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> + */ +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 (0–4) for all glass questions missing rules. + */ +function qdb_backfill_glass_score_rules(PDO $pdo): int { + $stmt = $pdo->query("SELECT questionID, configJson FROM question WHERE type = 'glass_scale_question'"); + $count = 0; + $defaults = qdb_default_glass_level_points(); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $existing = qdb_get_score_rules_for_question($pdo, $row['questionID']); + if ($existing !== []) { + continue; + } + $config = json_decode($row['configJson'] ?? '{}', true) ?: []; + $rules = []; + foreach ($config['symptoms'] ?? [] as $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk === '') { + continue; + } + foreach ($defaults as $levelKey => $pts) { + $rules[] = ['scopeKey' => $sk, 'levelKey' => $levelKey, 'points' => $pts]; + } + } + if ($rules !== []) { + qdb_sync_question_score_rules($pdo, $row['questionID'], $rules); + $count++; + } + } + return $count; +} + +/** + * Recompute questionnaire sumPoints and scoring-profile results for specific clients. + * + * @param list $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 0–12, yellow 13–36, 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 $clientCodes + * @return list>}> + */ +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 + */ +/** + * Store coach band; create a result row from app-local computed values when none exists yet. + * + * @return array + */ +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); +} diff --git a/lib/settings.php b/lib/settings.php new file mode 100644 index 0000000..6d644d6 --- /dev/null +++ b/lib/settings.php @@ -0,0 +1,114 @@ + 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 $partial + * @param array|null $base existing settings; defaults used when null + * @return array 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 $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']; +} diff --git a/lib/submissions.php b/lib/submissions.php new file mode 100644 index 0000000..49ddd08 --- /dev/null +++ b/lib/submissions.php @@ -0,0 +1,999 @@ +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> + */ +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> + */ +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 $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> $rows + * @param list $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 $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 $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> $answerMap questionID => answer row + * @param array>|null $compareMap live answers to diff against + * @return list + */ +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 $questionIDs + * @return array> + */ +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, byClient: array>} + */ +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 + */ +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; +} diff --git a/lib/text_sanitize.php b/lib/text_sanitize.php new file mode 100644 index 0000000..009859c --- /dev/null +++ b/lib/text_sanitize.php @@ -0,0 +1,61 @@ + 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); + } +} diff --git a/logs/api/.gitkeep b/logs/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3792903 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,30 @@ + + + + + tests/Unit + + + tests/Integration + + + + + + lib + handlers + db_init.php + common.php + + + tests + vendor + + + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..859d397 --- /dev/null +++ b/schema.sql @@ -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); + diff --git a/scripts/generate_dev_fixture.py b/scripts/generate_dev_fixture.py new file mode 100644 index 0000000..4235f8e --- /dev/null +++ b/scripts/generate_dev_fixture.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Generate dev-test-fixture.json for admin Dev tab import.""" +import json +import random +from datetime import datetime, timedelta +from pathlib import Path + +PREFIX = "dev_" +PASSWORD = "socialvrlab" +CLIENT_PREFIX = "DEV-CL-" +SCALE = 5 + +# Fixed RNG + anchor time so the file is reproducible but not grid-like. +RNG = random.Random(42) +ANCHOR = datetime(2026, 5, 27, 15, 30, 0) + +REPO_ROOT = Path(__file__).resolve().parents[2] + +FALLBACK_QUESTIONNAIRE_IDS = [ + "questionnaire_1_demographic_information", + "questionnaire_2_rhs", + "questionnaire_3_integration_index", + "questionnaire_4_consultation_results", + "questionnaire_5_final_interview", + "questionnaire_6_follow_up_survey", +] + +NUM_ADMINS = 2 * SCALE +NUM_SUPERVISORS = 3 * SCALE +NUM_COACHES = 20 * SCALE +NUM_CLIENTS = 100 * SCALE +CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE + +# Roughly how many clients (with a coach) get at least one questionnaire. +COMPLETION_PARTICIPATION = 0.82 + + +def resolve_bundle_path() -> Path | None: + """Use the newest questionnaires_bundle_*.json in the repo root.""" + candidates = sorted( + REPO_ROOT.glob("questionnaires_bundle_*.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + return candidates[0] if candidates else None + + +def load_questionnaire_ids(bundle_path: Path | None) -> list[str]: + if bundle_path is None or not bundle_path.is_file(): + return FALLBACK_QUESTIONNAIRE_IDS + bundle = json.loads(bundle_path.read_text(encoding="utf-8")) + ids = [ + item["questionnaire"]["questionnaireID"] + for item in bundle.get("questionnaires", []) + if item.get("questionnaire", {}).get("questionnaireID") + ] + return ids or FALLBACK_QUESTIONNAIRE_IDS + + +def ts_days_ago(min_days: float, max_days: float) -> int: + days = RNG.uniform(min_days, max_days) + return int((ANCHOR - timedelta(days=days)).timestamp()) + + +def variant_seed(client_code: str, qn_id: str, version_index: int) -> int: + raw = f"{client_code}:{qn_id}:v{version_index}" + return int.from_bytes(raw.encode(), "big") % 100_000 + + +def assign_coaches(clients: list[dict], coaches: list[dict]) -> None: + """Uneven coach load: a few busy coaches, several light ones.""" + coach_names = [c["username"] for c in coaches] + weights = [] + for i, _ in enumerate(coach_names): + # Coaches 1–4 and 7, 12 get more clients; tail is lighter. + base = RNG.uniform(0.4, 1.0) + if (i % 7) in (0, 1, 2): + base *= RNG.uniform(2.0, 4.5) + if i % 11 == 0: + base *= RNG.uniform(0.15, 0.45) + weights.append(base) + + for row in clients: + row["coach"] = RNG.choices(coach_names, weights=weights, k=1)[0] + + +def build_completions( + questionnaire_ids: list[str], + clients_with_data: list[str], +) -> list[dict]: + """ + Natural-ish completion patterns: + - Sequential questionnaire funnel (not random qn per slot) + - Irregular timestamps and gaps between uploads + - ~20% of client/qn pairs have 2–4 archived versions + """ + completions: list[dict] = [] + qn_count = len(questionnaire_ids) + + # How many questionnaires a client completes (skewed toward mid funnel). + qn_count_weights = [8, 14, 22, 24, 18, 10, 4][:qn_count] + if len(qn_count_weights) < qn_count: + qn_count_weights += [2] * (qn_count - len(qn_count_weights)) + + for client_code in clients_with_data: + if RNG.random() > COMPLETION_PARTICIPATION: + continue + + num_qn = RNG.choices( + list(range(1, qn_count + 1)), + weights=qn_count_weights[:qn_count], + k=1, + )[0] + selected = questionnaire_ids[:num_qn] + + # First activity somewhere in the last ~6 months; some clients are very recent. + cursor = ts_days_ago(3, 185) + if RNG.random() < 0.12: + cursor = ts_days_ago(0.5, 14) + + for qn_id in selected: + version_count = 1 + roll = RNG.random() + if roll < 0.08 and num_qn >= 2: + version_count = 4 + elif roll < 0.20: + version_count = 3 + elif roll < 0.38: + version_count = 2 + + uploads = [] + for v in range(version_count): + if v > 0: + # Re-upload after days or weeks (sometimes same day correction). + if RNG.random() < 0.18: + gap_sec = RNG.randint(2 * 3600, 36 * 3600) + else: + gap_sec = RNG.randint(2 * 86400, 55 * 86400) + cursor += gap_sec + else: + # First upload for this qn: often a few days after previous qn. + cursor += RNG.randint(0, 12 * 86400) + + duration = RNG.randint(4 * 60, 55 * 60) + started = cursor - duration + completed = cursor + RNG.randint(0, 120) + + uploads.append({ + "submittedAt": completed, + "startedAt": started, + "variant": variant_seed(client_code, qn_id, v), + }) + + completions.append({ + "clientCode": client_code, + "questionnaireID": qn_id, + "versions": version_count, + "uploads": uploads, + }) + + RNG.shuffle(completions) + return completions + + +def main(): + bundle_path = resolve_bundle_path() + questionnaire_ids = load_questionnaire_ids(bundle_path) + + admins = [ + {"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"} + for i in range(1, NUM_ADMINS + 1) + ] + supervisors = [ + {"username": f"{PREFIX}supervisor_{i}", "location": f"Dev Supervisor Region {i}"} + for i in range(1, NUM_SUPERVISORS + 1) + ] + coaches = [] + for i in range(1, NUM_COACHES + 1): + sv = (i - 1) % NUM_SUPERVISORS + 1 + coaches.append({ + "username": f"{PREFIX}coach_{i:02d}", + "supervisor": f"{PREFIX}supervisor_{sv}", + }) + + clients = [ + {"clientCode": f"{CLIENT_PREFIX}{i:04d}"} + for i in range(1, NUM_CLIENTS + 1) + ] + assign_coaches(clients, coaches) + + all_codes = [c["clientCode"] for c in clients] + no_data_codes = set(RNG.sample(all_codes, min(CLIENTS_WITHOUT_COMPLETIONS, len(all_codes)))) + clients_with_data = [c for c in all_codes if c not in no_data_codes] + + completions = build_completions(questionnaire_ids, clients_with_data) + total_uploads = sum(c["versions"] for c in completions) + multi_version_pairs = sum(1 for c in completions if c["versions"] > 1) + + bundle_note = bundle_path.name if bundle_path and bundle_path.is_file() else "fallback questionnaire list" + + fixture = { + "fixtureVersion": 2, + "description": ( + f"Dev/test users, clients, completions with uneven coach load and upload history " + f"({SCALE}x scale). Questionnaires from {bundle_note}. Password: socialvrlab." + ), + "prefix": PREFIX, + "defaultPassword": PASSWORD, + "sourceBundle": bundle_note, + "questionnaireIDs": questionnaire_ids, + "admins": admins, + "supervisors": supervisors, + "coaches": coaches, + "clients": clients, + "completions": completions, + "stats": { + "scale": SCALE, + "admins": len(admins), + "supervisors": len(supervisors), + "coaches": len(coaches), + "clients": len(clients), + "clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS, + "completionRecords": len(completions), + "totalUploads": total_uploads, + "multiVersionPairs": multi_version_pairs, + "questionnaires": len(questionnaire_ids), + }, + } + + out = REPO_ROOT / "dev-test-fixture.json" + out.write_text(json.dumps(fixture, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {out}") + print(json.dumps(fixture["stats"], indent=2)) + + +if __name__ == "__main__": + main() diff --git a/seed_admin.php b/seed_admin.php new file mode 100644 index 0000000..e3981bf --- /dev/null +++ b/seed_admin.php @@ -0,0 +1,66 @@ + [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 [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); +} diff --git a/tests/Integration/ApiRoutingTest.php b/tests/Integration/ApiRoutingTest.php new file mode 100644 index 0000000..17a9184 --- /dev/null +++ b/tests/Integration/ApiRoutingTest.php @@ -0,0 +1,22 @@ +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); + } +} diff --git a/tests/Integration/AppQuestionnairesBulkTest.php b/tests/Integration/AppQuestionnairesBulkTest.php new file mode 100644 index 0000000..d69691a --- /dev/null +++ b/tests/Integration/AppQuestionnairesBulkTest.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/tests/Integration/AppQuestionnairesTest.php b/tests/Integration/AppQuestionnairesTest.php new file mode 100644 index 0000000..664c9d3 --- /dev/null +++ b/tests/Integration/AppQuestionnairesTest.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/tests/Integration/AppSubmitExtendedTest.php b/tests/Integration/AppSubmitExtendedTest.php new file mode 100644 index 0000000..7c21459 --- /dev/null +++ b/tests/Integration/AppSubmitExtendedTest.php @@ -0,0 +1,271 @@ +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); + } +} diff --git a/tests/Integration/AppSubmitLegacyTest.php b/tests/Integration/AppSubmitLegacyTest.php new file mode 100644 index 0000000..3f8eeca --- /dev/null +++ b/tests/Integration/AppSubmitLegacyTest.php @@ -0,0 +1,78 @@ +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); + } +} diff --git a/tests/Integration/AssignmentsActivityLogTest.php b/tests/Integration/AssignmentsActivityLogTest.php new file mode 100644 index 0000000..2f57454 --- /dev/null +++ b/tests/Integration/AssignmentsActivityLogTest.php @@ -0,0 +1,38 @@ +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']); + } +} diff --git a/tests/Integration/AuthTest.php b/tests/Integration/AuthTest.php new file mode 100644 index 0000000..0eba9d8 --- /dev/null +++ b/tests/Integration/AuthTest.php @@ -0,0 +1,68 @@ +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); + } +} diff --git a/tests/Integration/BackupDevOpsTest.php b/tests/Integration/BackupDevOpsTest.php new file mode 100644 index 0000000..d2f05f0 --- /dev/null +++ b/tests/Integration/BackupDevOpsTest.php @@ -0,0 +1,80 @@ +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); + } + +} diff --git a/tests/Integration/CatalogApiTest.php b/tests/Integration/CatalogApiTest.php new file mode 100644 index 0000000..75ea251 --- /dev/null +++ b/tests/Integration/CatalogApiTest.php @@ -0,0 +1,94 @@ +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']); + } +} diff --git a/tests/Integration/ChangePasswordTest.php b/tests/Integration/ChangePasswordTest.php new file mode 100644 index 0000000..b7e66af --- /dev/null +++ b/tests/Integration/ChangePasswordTest.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/tests/Integration/ClientsLifecycleTest.php b/tests/Integration/ClientsLifecycleTest.php new file mode 100644 index 0000000..2225b9d --- /dev/null +++ b/tests/Integration/ClientsLifecycleTest.php @@ -0,0 +1,114 @@ +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); + } +} diff --git a/tests/Integration/ClientsQuestionnairesTest.php b/tests/Integration/ClientsQuestionnairesTest.php new file mode 100644 index 0000000..2c18813 --- /dev/null +++ b/tests/Integration/ClientsQuestionnairesTest.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/tests/Integration/CoachesActivityTest.php b/tests/Integration/CoachesActivityTest.php new file mode 100644 index 0000000..6a28884 --- /dev/null +++ b/tests/Integration/CoachesActivityTest.php @@ -0,0 +1,27 @@ +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']); + } +} diff --git a/tests/Integration/DbInitTest.php b/tests/Integration/DbInitTest.php new file mode 100644 index 0000000..965225b --- /dev/null +++ b/tests/Integration/DbInitTest.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/tests/Integration/DownloadsTest.php b/tests/Integration/DownloadsTest.php new file mode 100644 index 0000000..35db2f4 --- /dev/null +++ b/tests/Integration/DownloadsTest.php @@ -0,0 +1,50 @@ +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']); + } +} diff --git a/tests/Integration/EditorCrudTest.php b/tests/Integration/EditorCrudTest.php new file mode 100644 index 0000000..bc007ec --- /dev/null +++ b/tests/Integration/EditorCrudTest.php @@ -0,0 +1,119 @@ +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); + } +} diff --git a/tests/Integration/ExportExtendedTest.php b/tests/Integration/ExportExtendedTest.php new file mode 100644 index 0000000..708a55d --- /dev/null +++ b/tests/Integration/ExportExtendedTest.php @@ -0,0 +1,116 @@ +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']); + } +} diff --git a/tests/Integration/ExportTest.php b/tests/Integration/ExportTest.php new file mode 100644 index 0000000..d03852b --- /dev/null +++ b/tests/Integration/ExportTest.php @@ -0,0 +1,50 @@ +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']); + } +} diff --git a/tests/Integration/QuestionnaireEditingTest.php b/tests/Integration/QuestionnaireEditingTest.php new file mode 100644 index 0000000..238df1e --- /dev/null +++ b/tests/Integration/QuestionnaireEditingTest.php @@ -0,0 +1,57 @@ +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); + } +} diff --git a/tests/Integration/QuestionnaireImportExportTest.php b/tests/Integration/QuestionnaireImportExportTest.php new file mode 100644 index 0000000..833c58b --- /dev/null +++ b/tests/Integration/QuestionnaireImportExportTest.php @@ -0,0 +1,76 @@ +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'] ?? ''); + } +} diff --git a/tests/Integration/RbacIntegrationTest.php b/tests/Integration/RbacIntegrationTest.php new file mode 100644 index 0000000..479d76c --- /dev/null +++ b/tests/Integration/RbacIntegrationTest.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/tests/Integration/ResultsAnalyticsTest.php b/tests/Integration/ResultsAnalyticsTest.php new file mode 100644 index 0000000..e14484e --- /dev/null +++ b/tests/Integration/ResultsAnalyticsTest.php @@ -0,0 +1,86 @@ +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']); + } +} diff --git a/tests/Integration/SecurityPathsTest.php b/tests/Integration/SecurityPathsTest.php new file mode 100644 index 0000000..61217a5 --- /dev/null +++ b/tests/Integration/SecurityPathsTest.php @@ -0,0 +1,90 @@ +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); + } +} diff --git a/tests/Integration/SessionLogoutTest.php b/tests/Integration/SessionLogoutTest.php new file mode 100644 index 0000000..75de33f --- /dev/null +++ b/tests/Integration/SessionLogoutTest.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/tests/Integration/SettingsApiTest.php b/tests/Integration/SettingsApiTest.php new file mode 100644 index 0000000..5a6bad8 --- /dev/null +++ b/tests/Integration/SettingsApiTest.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/tests/Integration/TokenTest.php b/tests/Integration/TokenTest.php new file mode 100644 index 0000000..a7efcf4 --- /dev/null +++ b/tests/Integration/TokenTest.php @@ -0,0 +1,65 @@ +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']); + } +} diff --git a/tests/Integration/TranslationsImportExportTest.php b/tests/Integration/TranslationsImportExportTest.php new file mode 100644 index 0000000..c9fbd05 --- /dev/null +++ b/tests/Integration/TranslationsImportExportTest.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/tests/Integration/TranslationsWriteTest.php b/tests/Integration/TranslationsWriteTest.php new file mode 100644 index 0000000..a9dc9bd --- /dev/null +++ b/tests/Integration/TranslationsWriteTest.php @@ -0,0 +1,104 @@ +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); + } +} diff --git a/tests/Integration/UsersAdminTest.php b/tests/Integration/UsersAdminTest.php new file mode 100644 index 0000000..41ffa26 --- /dev/null +++ b/tests/Integration/UsersAdminTest.php @@ -0,0 +1,179 @@ +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); + } +} diff --git a/tests/Integration/UsersTest.php b/tests/Integration/UsersTest.php new file mode 100644 index 0000000..24cca07 --- /dev/null +++ b/tests/Integration/UsersTest.php @@ -0,0 +1,55 @@ +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); + } +} diff --git a/tests/Support/ApiClient.php b/tests/Support/ApiClient.php new file mode 100644 index 0000000..f133240 --- /dev/null +++ b/tests/Support/ApiClient.php @@ -0,0 +1,181 @@ + $headers + * @return array{ok: bool, status: int, data?: mixed, error?: array} + */ + 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} + */ + 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} + */ + 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 $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 $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 $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); + } +} diff --git a/tests/Support/DatabaseSeeder.php b/tests/Support/DatabaseSeeder.php new file mode 100644 index 0000000..ee4a45d --- /dev/null +++ b/tests/Support/DatabaseSeeder.php @@ -0,0 +1,233 @@ +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, + ) { + } +} diff --git a/tests/Support/JsonErrorResponse.php b/tests/Support/JsonErrorResponse.php new file mode 100644 index 0000000..4048527 --- /dev/null +++ b/tests/Support/JsonErrorResponse.php @@ -0,0 +1,20 @@ +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} + */ + 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 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> $answers + * @return array + */ + 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; + } +} diff --git a/tests/Support/RawHttpResponse.php b/tests/Support/RawHttpResponse.php new file mode 100644 index 0000000..df965f1 --- /dev/null +++ b/tests/Support/RawHttpResponse.php @@ -0,0 +1,22 @@ + $headers + */ + public function __construct( + public readonly string $body, + public readonly array $headers, + public readonly int $httpStatus = 200, + ) { + parent::__construct('Raw HTTP response', $httpStatus); + } +} diff --git a/tests/Support/TestFilesystem.php b/tests/Support/TestFilesystem.php new file mode 100644 index 0000000..c311037 --- /dev/null +++ b/tests/Support/TestFilesystem.php @@ -0,0 +1,48 @@ +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); + } + } +} diff --git a/tests/Unit/AppAnswersTest.php b/tests/Unit/AppAnswersTest.php new file mode 100644 index 0000000..8609e72 --- /dev/null +++ b/tests/Unit/AppAnswersTest.php @@ -0,0 +1,74 @@ +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']); + } +} diff --git a/tests/Unit/AppSubmitValidateTest.php b/tests/Unit/AppSubmitValidateTest.php new file mode 100644 index 0000000..3772bde --- /dev/null +++ b/tests/Unit/AppSubmitValidateTest.php @@ -0,0 +1,131 @@ +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'] ?? ''); + } +} diff --git a/tests/Unit/CommonBundleTest.php b/tests/Unit/CommonBundleTest.php new file mode 100644 index 0000000..b47dd84 --- /dev/null +++ b/tests/Unit/CommonBundleTest.php @@ -0,0 +1,62 @@ + []]); + $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); + } +} diff --git a/tests/Unit/CommonHelpersTest.php b/tests/Unit/CommonHelpersTest.php new file mode 100644 index 0000000..0db7995 --- /dev/null +++ b/tests/Unit/CommonHelpersTest.php @@ -0,0 +1,100 @@ +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); + } +} diff --git a/tests/Unit/CryptoTest.php b/tests/Unit/CryptoTest.php new file mode 100644 index 0000000..3f44fc1 --- /dev/null +++ b/tests/Unit/CryptoTest.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/tests/Unit/EncryptedPayloadTest.php b/tests/Unit/EncryptedPayloadTest.php new file mode 100644 index 0000000..e1097ec --- /dev/null +++ b/tests/Unit/EncryptedPayloadTest.php @@ -0,0 +1,51 @@ +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))); + } +} diff --git a/tests/Unit/ErrorsTest.php b/tests/Unit/ErrorsTest.php new file mode 100644 index 0000000..2736a42 --- /dev/null +++ b/tests/Unit/ErrorsTest.php @@ -0,0 +1,56 @@ +assertTrue(qdb_is_lock_exception(new Exception('Could not acquire lock'))); + $this->assertTrue(qdb_is_lock_exception(new Exception('Could not open lock file'))); + $this->assertFalse(qdb_is_lock_exception(new Exception('other'))); + } + + public function testPublicErrorMessageForLock(): void + { + $msg = qdb_public_error_message(new Exception('Could not acquire lock'), 'Save'); + $this->assertStringContainsString('busy', strtolower($msg)); + } + + public function testPublicErrorMessageForDecrypt(): void + { + $msg = qdb_public_error_message(new Exception('decrypt failed'), 'Open'); + $this->assertStringContainsString('configuration', strtolower($msg)); + } + + public function testPublicErrorMessageForPdo(): void + { + $msg = qdb_public_error_message(new PDOException('SQLITE_CONSTRAINT'), 'Query'); + $this->assertStringContainsString('database error', strtolower($msg)); + } + + public function testApiErrorForLockUses503(): void + { + [$code, , $status] = qdb_api_error_for_exception(new Exception('Could not acquire lock'), 'Write'); + $this->assertSame('LOCKED', $code); + $this->assertSame(503, $status); + } + + public function testApiErrorForGenericUses500(): void + { + [$code, , $status] = qdb_api_error_for_exception(new Exception('boom'), 'Op'); + $this->assertSame('SERVER_ERROR', $code); + $this->assertSame(500, $status); + } +} diff --git a/tests/Unit/LoginRateLimitTest.php b/tests/Unit/LoginRateLimitTest.php new file mode 100644 index 0000000..7b9e641 --- /dev/null +++ b/tests/Unit/LoginRateLimitTest.php @@ -0,0 +1,58 @@ + 3, + 'login_window_seconds' => 300, + 'login_lockout_seconds' => 600, + ]; + [$ok, $retry] = qdb_login_rate_limit_check('user_a', $settings); + $this->assertTrue($ok); + $this->assertSame(0, $retry); + } + + public function testLocksAfterMaxFailures(): void + { + $settings = [ + 'login_max_attempts' => 2, + 'login_window_seconds' => 300, + 'login_lockout_seconds' => 600, + ]; + $user = 'lock_test_' . bin2hex(random_bytes(4)); + qdb_login_rate_limit_record_failure($user, $settings); + qdb_login_rate_limit_record_failure($user, $settings); + [$ok, $retry] = qdb_login_rate_limit_check($user, $settings); + $this->assertFalse($ok); + $this->assertGreaterThan(0, $retry); + } + + public function testClearResetsCounter(): void + { + $settings = [ + 'login_max_attempts' => 1, + 'login_window_seconds' => 300, + 'login_lockout_seconds' => 600, + ]; + $user = 'clear_test_' . bin2hex(random_bytes(4)); + qdb_login_rate_limit_record_failure($user, $settings); + qdb_login_rate_limit_clear($user); + [$ok,] = qdb_login_rate_limit_check($user, $settings); + $this->assertTrue($ok); + } +} diff --git a/tests/Unit/QuestionnaireStructureTest.php b/tests/Unit/QuestionnaireStructureTest.php new file mode 100644 index 0000000..1096494 --- /dev/null +++ b/tests/Unit/QuestionnaireStructureTest.php @@ -0,0 +1,56 @@ +fixture(); + + $before = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId); + $newRev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'test_bump'); + $after = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId); + + $this->assertSame($before + 1, $newRev); + $this->assertSame($newRev, $after); + + $manifest = qdb_load_structure_manifest($pdo, $f->questionnaireId, $before); + $this->assertNotNull($manifest); + $this->assertNotEmpty($manifest['questions'] ?? []); + $this->assertSame($before, (int)($manifest['structureRevision'] ?? 0)); + + qdb_save($tmpDb, $lockFp); + } + + public function testRetirePreservesClientAnswers(): void + { + require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php'; + $this->assertApiOk($this->submitFixtureQuestionnaire()); + + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $f = $this->fixture(); + + $this->assertTrue(qdb_question_has_client_data($pdo, $f->questionId)); + $rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'retire_test'); + qdb_retire_question($pdo, $f->questionId, $rev); + + $row = $pdo->prepare('SELECT retiredAt FROM question WHERE questionID = :id'); + $row->execute([':id' => $f->questionId]); + $this->assertGreaterThan(0, (int)$row->fetchColumn()); + + $ans = $pdo->prepare( + 'SELECT COUNT(*) FROM client_answer WHERE clientCode = :cc AND questionID = :qid' + ); + $ans->execute([':cc' => $f->clientCode, ':qid' => $f->questionId]); + $this->assertSame(1, (int)$ans->fetchColumn()); + + qdb_save($tmpDb, $lockFp); + } +} diff --git a/tests/Unit/RbacTest.php b/tests/Unit/RbacTest.php new file mode 100644 index 0000000..1ad807b --- /dev/null +++ b/tests/Unit/RbacTest.php @@ -0,0 +1,48 @@ + 'admin', 'entityID' => 'a1'], 'cl'); + $this->assertStringContainsString('1=1', $clause); + $this->assertStringContainsString('archived', $clause); + $this->assertSame([], $params); + + [$allClause] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl', 'all'); + $this->assertStringNotContainsString('archived', $allClause); + } + + public function testSupervisorFilterBindsEntity(): void + { + [$clause, $params] = rbac_client_filter( + ['role' => 'supervisor', 'entityID' => 'sv-99'], + 'cl' + ); + $this->assertStringContainsString('supervisorID', $clause); + $this->assertSame([':rbac_eid' => 'sv-99'], $params); + } + + public function testCoachFilterBindsCoach(): void + { + [$clause, $params] = rbac_client_filter( + ['role' => 'coach', 'entityID' => 'coach-1'], + 'cl' + ); + $this->assertStringContainsString('coachID', $clause); + $this->assertSame([':rbac_eid' => 'coach-1'], $params); + } + + public function testWebRolesList(): void + { + $this->assertContains('admin', qdb_web_roles()); + $this->assertContains('supervisor', qdb_web_roles()); + $this->assertNotContains('coach', qdb_web_roles()); + } +} diff --git a/tests/Unit/ScoringTest.php b/tests/Unit/ScoringTest.php new file mode 100644 index 0000000..731c489 --- /dev/null +++ b/tests/Unit/ScoringTest.php @@ -0,0 +1,211 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->exec('PRAGMA foreign_keys = ON'); + $schema = file_get_contents(dirname(__DIR__, 2) . '/schema.sql'); + $this->pdo->exec($schema); + } + + public function testBandForTotal(): void + { + $legacy = ['greenMax' => 12, 'yellowMax' => 36]; + $this->assertSame('green', qdb_band_for_total(0, $legacy)); + $this->assertSame('green', qdb_band_for_total(12, $legacy)); + $this->assertSame('yellow', qdb_band_for_total(13, $legacy)); + $this->assertSame('yellow', qdb_band_for_total(36, $legacy)); + $this->assertSame('red', qdb_band_for_total(37, $legacy)); + + $custom = [ + 'greenMin' => 5, + 'greenMax' => 15, + 'yellowMin' => 16, + 'yellowMax' => 30, + 'redMin' => 31, + ]; + $this->assertSame('green', qdb_band_for_total(5, $custom)); + $this->assertSame('green', qdb_band_for_total(15, $custom)); + $this->assertSame('yellow', qdb_band_for_total(16, $custom)); + $this->assertSame('red', qdb_band_for_total(31, $custom)); + } + + public function testValidateScoringBands(): void + { + $this->assertNull(qdb_validate_scoring_bands(qdb_normalize_scoring_bands(['greenMax' => 12, 'yellowMax' => 36]))); + $this->assertNotNull(qdb_validate_scoring_bands([ + 'greenMin' => 0, 'greenMax' => 20, + 'yellowMin' => 15, 'yellowMax' => 36, + 'redMin' => 37, + ])); + } + + public function testGlassScoreRulesFromSymptoms(): void + { + $rules = qdb_score_rules_from_glass_symptoms([ + ['key' => 'pain', 'scoreLevels' => ['never_glass' => 0, 'extreme_glass' => 5]], + ]); + $this->assertCount(5, $rules); + $painExtreme = array_values(array_filter( + $rules, + fn ($r) => $r['scopeKey'] === 'pain' && $r['levelKey'] === 'extreme_glass' + )); + $this->assertSame(5, $painExtreme[0]['points'] ?? null); + } + + private function seedCoachChain(): void + { + $this->pdo->exec("INSERT INTO supervisor (supervisorID, username) VALUES ('sup1', 'sup')"); + $this->pdo->exec("INSERT INTO coach (coachID, supervisorID, username) VALUES ('coach1', 'sup1', 'coach')"); + } + + public function testRadioAndGlassScoring(): void + { + $this->seedCoachChain(); + $qnID = 'qn_test'; + $this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) + VALUES ('$qnID', 'Test', '1', 'active', 0, 0, '{}', '')"); + + $radioQ = "{$qnID}__radio"; + $this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) + VALUES ('$radioQ', '$qnID', 'Pick one', 'radio_question', 0, 1, '{}')"); + $this->pdo->exec("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) + VALUES ('ao1', '$radioQ', 'yes', 3, 0, '')"); + + $glassQ = "{$qnID}__glass"; + $this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) + VALUES ('$glassQ', '$qnID', 'Symptoms', 'glass_scale_question', 1, 1, '{\"symptoms\":[\"pain\"]}')"); + qdb_sync_question_score_rules($this->pdo, $glassQ, [ + ['scopeKey' => 'pain', 'levelKey' => 'moderate_glass', 'points' => 2], + ]); + + $client = 'CLIENT-001'; + $this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')"); + $this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue) + VALUES ('$client', '$radioQ', 'ao1', NULL, NULL)"); + $this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue) + VALUES ('$client', '$glassQ', NULL, '{\"pain\":\"moderate_glass\"}', NULL)"); + + $score = qdb_compute_questionnaire_score($this->pdo, $client, $qnID); + $this->assertSame(5, $score); + } + + public function testIncompleteProfileProducesNoResult(): void + { + $this->seedCoachChain(); + $qnA = 'qn_a'; + $qnB = 'qn_b'; + foreach ([$qnA, $qnB] as $id) { + $this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) + VALUES ('$id', '$id', '1', 'active', 0, 0, '{}', '')"); + } + $profileID = 'profile1'; + $this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive, + greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) + VALUES ('$profileID', 'Combo', '', 1, 0, 10, 11, 20, 21, 0, 0)"); + $this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex) + VALUES ('$profileID', '$qnA', 1.0, 0), ('$profileID', '$qnB', 1.0, 1)"); + + $client = 'CLIENT-002'; + $this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')"); + $this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt) + VALUES ('$client', '$qnA', 'completed', 5, 1)"); + + qdb_recompute_profile_scores_for_client($this->pdo, $client); + + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM client_scoring_profile_result WHERE clientCode = :cc'); + $stmt->execute([':cc' => $client]); + $this->assertSame(0, (int)$stmt->fetchColumn()); + } + + public function testCoachScoringReviewPreservesComputedBand(): void + { + $this->seedCoachChain(); + $qnID = 'qn_review'; + $this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) + VALUES ('$qnID', 'Review', '1', 'active', 0, 0, '{}', '')"); + $profileID = 'profile_review'; + $this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive, + greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) + VALUES ('$profileID', 'Review profile', '', 1, 0, 10, 11, 20, 21, 0, 0)"); + $this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex) + VALUES ('$profileID', '$qnID', 1.0, 0)"); + + $client = 'CLIENT-REVIEW'; + $this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')"); + $this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt) + VALUES ('$client', '$qnID', 'completed', 15, 1)"); + + qdb_recompute_profile_scores_for_client($this->pdo, $client); + + $row = $this->pdo->query( + "SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'" + )->fetch(PDO::FETCH_ASSOC); + $this->assertSame('yellow', $row['band']); + $this->assertSame('', $row['coachBand']); + + qdb_set_coach_scoring_band($this->pdo, $client, $profileID, 'green', 'user1'); + + $row = $this->pdo->query( + "SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'" + )->fetch(PDO::FETCH_ASSOC); + $this->assertSame('yellow', $row['band']); + $this->assertSame('green', $row['coachBand']); + + qdb_recompute_profile_scores_for_client($this->pdo, $client); + $row = $this->pdo->query( + "SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'" + )->fetch(PDO::FETCH_ASSOC); + $this->assertSame('yellow', $row['band']); + $this->assertSame('green', $row['coachBand']); + } + + public function testSaveCoachReviewFromAppBeforeServerRecompute(): void + { + $this->seedCoachChain(); + $qnID = 'qn_pre'; + $this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) + VALUES ('$qnID', 'Pre', '1', 'active', 0, 0, '{}', '')"); + $profileID = 'profile_pre'; + $this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive, + greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) + VALUES ('$profileID', 'Pre profile', '', 1, 0, 10, 11, 20, 21, 0, 0)"); + $this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex) + VALUES ('$profileID', '$qnID', 1.0, 0)"); + + $client = 'CLIENT-PRE'; + $this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')"); + + qdb_save_coach_scoring_review( + $this->pdo, + $client, + $profileID, + 'yellow', + 'user1', + 15.0, + 'yellow' + ); + + $row = $this->pdo->query( + "SELECT band, coachBand, weightedTotal FROM client_scoring_profile_result WHERE clientCode = '$client'" + )->fetch(PDO::FETCH_ASSOC); + $this->assertSame('yellow', $row['band']); + $this->assertSame('yellow', $row['coachBand']); + $this->assertSame(15.0, (float)$row['weightedTotal']); + } +} diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php new file mode 100644 index 0000000..75ee315 --- /dev/null +++ b/tests/Unit/SettingsTest.php @@ -0,0 +1,54 @@ +assertSame(10, $d['login_max_attempts']); + $this->assertSame(30 * 24 * 60 * 60, $d['session_ttl_seconds']); + } + + public function testValidateRejectsOutOfRange(): void + { + require_once dirname(__DIR__, 2) . '/lib/settings.php'; + $this->expectException(InvalidArgumentException::class); + qdb_settings_validate_and_merge(['login_max_attempts' => 0]); + } + + public function testSaveAndLoadRoundTrip(): void + { + require_once dirname(__DIR__, 2) . '/lib/settings.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $custom = qdb_settings_validate_and_merge([ + 'login_max_attempts' => 5, + 'login_window_seconds' => 600, + 'login_lockout_seconds' => 1200, + 'session_ttl_seconds' => 86400, + 'temp_session_ttl_seconds' => 900, + ]); + qdb_settings_save_on_pdo($pdo, $custom); + qdb_save($tmpDb, $lockFp); + [$pdo2, $tmp2, $lock2] = qdb_open(false); + $loaded = qdb_settings_get_on_pdo($pdo2); + qdb_discard($tmp2, $lock2); + $this->assertSame(5, $loaded['login_max_attempts']); + $this->assertSame(86400, $loaded['session_ttl_seconds']); + } + + public function testSessionTtlHelper(): void + { + require_once dirname(__DIR__, 2) . '/lib/settings.php'; + $this->assertSame(600, qdb_session_ttl_seconds(['temp_session_ttl_seconds' => 600], true)); + $this->assertSame(3600, qdb_session_ttl_seconds(['session_ttl_seconds' => 3600], false)); + } +} diff --git a/tests/Unit/TextSanitizeTest.php b/tests/Unit/TextSanitizeTest.php new file mode 100644 index 0000000..f97fbb9 --- /dev/null +++ b/tests/Unit/TextSanitizeTest.php @@ -0,0 +1,31 @@ +assertStringNotContainsString('DROP TABLE', $out); + $this->assertStringNotContainsString('--', $out); + } + + public function testPreservesNewlines(): void + { + require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php'; + $out = sanitize_free_text("line1\nline2"); + $this->assertStringContainsString("\n", $out); + } + + public function testNonStringReturnsEmpty(): void + { + require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php'; + $this->assertSame('', sanitize_free_text(null)); + } +} diff --git a/tests/Unit/ValidateTest.php b/tests/Unit/ValidateTest.php new file mode 100644 index 0000000..d5f56fc --- /dev/null +++ b/tests/Unit/ValidateTest.php @@ -0,0 +1,55 @@ + '1', 'b' => 'x']; + require_fields($body, ['a', 'b']); + $this->assertSame(['a' => '1', 'b' => 'x'], $body); + } + + public function testRequireFieldsMissing(): void + { + $this->expectException(JsonErrorResponse::class); + $this->expectExceptionMessage('Required fields'); + try { + require_fields(['a' => ''], ['a', 'b']); + } catch (JsonErrorResponse $e) { + $this->assertSame('MISSING_FIELDS', $e->errorCode); + $this->assertSame(400, $e->httpStatus); + throw $e; + } + } + + public function testValidateStringTrims(): void + { + $s = validate_string(' hello ', 'field', 1, 20); + $this->assertSame('hello', $s); + } + + public function testValidateEnum(): void + { + $this->assertSame('admin', validate_enum('admin', 'role', ['admin', 'coach'])); + } + + public function testValidateIntBounds(): void + { + $this->assertSame(5, validate_int('5', 'n', 1, 10)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8c10c6d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,29 @@ +&1', + $envPrefix, + escapeshellarg(PHP_BINARY), + escapeshellarg($root) +); + +$output = []; +exec($cmd, $output, $exitCode); +echo implode("\n", $output) . "\n"; + +if ($exitCode !== 0) { + exit($exitCode); +} + +$linesPct = null; +foreach ($output as $line) { + // PHPUnit 11 / Xdebug: "Lines: 25.33% (1484/5859)" + if (preg_match('/^\s*Lines:\s+([\d.]+)%\s+\(/', $line, $m)) { + $linesPct = (float)$m[1]; + break; + } + // PCOV / older format: "Lines: 1484/5859 (25.33%)" + if (preg_match('/^\s*Lines:\s+[\d.]+\/\d+\s+\(\s*([\d.]+)%/', $line, $m)) { + $linesPct = (float)$m[1]; + break; + } +} + +if ($linesPct === null) { + fwrite(STDERR, "Could not parse coverage summary. Is PCOV or Xdebug enabled?\n"); + exit(1); +} + +if ($linesPct < $min) { + fwrite(STDERR, sprintf("Line coverage %.2f%% is below minimum %.2f%%\n", $linesPct, $min)); + exit(1); +} + +fwrite(STDERR, sprintf("Line coverage %.2f%% meets minimum %.2f%%\n", $linesPct, $min)); +exit(0); diff --git a/uploads/logs/.gitkeep b/uploads/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uploads/logs/api/.gitkeep b/uploads/logs/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/website/css/style.css b/website/css/style.css new file mode 100644 index 0000000..ec4549b --- /dev/null +++ b/website/css/style.css @@ -0,0 +1,3975 @@ +:root { + color-scheme: light; + --bg: #f3f7f6; + --surface: #ffffff; + --surface-raised: #fbfdfd; + --surface-hover: #eaf3f0; + --surface-muted: #f4f8f7; + --surface-input: #ffffff; + --surface-elevated: #fbfdfd; + --primary: #0f766e; + --primary-hover: #0b5f58; + --primary-subtle: rgba(15, 118, 110, 0.1); + --primary-border: rgba(15, 118, 110, 0.28); + --primary-text: #0f766e; + --danger: #dc2626; + --danger-hover: #b91c1c; + --danger-subtle: rgba(220, 38, 38, 0.08); + --danger-subtle-hover: rgba(220, 38, 38, 0.14); + --danger-border: rgba(220, 38, 38, 0.35); + --danger-text: #b91c1c; + --danger-focus-ring: rgba(220, 38, 38, 0.25); + --success: #059669; + --warning: #d97706; + --text: #102a27; + --text-secondary: #5d746f; + --text-on-primary: #ffffff; + --border: #d5e4e0; + --radius: 8px; + --radius-lg: 16px; + --shadow: 0 1px 2px rgba(16, 42, 39, 0.06), 0 1px 3px rgba(16, 42, 39, 0.08); + --shadow-lg: 0 8px 24px rgba(16, 42, 39, 0.08), 0 2px 6px rgba(16, 42, 39, 0.04); + --shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.85); + --focus-ring: rgba(15, 118, 110, 0.22); + --sidebar-width: 240px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --table-header-bg: #eaf3f0; + --table-stripe-bg: rgba(234, 243, 240, 0.85); + --row-hover-bg: #e7f5f1; + --sticky-shadow: rgba(16, 42, 39, 0.08); + --atmosphere-blue: rgba(15, 118, 110, 0.09); + --atmosphere-indigo: rgba(14, 116, 144, 0.06); + --grid-line: rgba(93, 116, 111, 0.1); + --accent-gradient: linear-gradient(135deg, #0f766e 0%, #0e7490 45%, #059669 100%); + --accent-gradient-soft: linear-gradient(90deg, rgba(15, 118, 110, 0.55), rgba(14, 116, 144, 0.4)); + --accent-glow: 0 4px 18px rgba(15, 118, 110, 0.18); + --shine-line: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), transparent); + /* Incomplete translation highlight */ + --trans-missing-bg: #fffbeb; + --trans-missing-bg-hover: #fef3c7; + --trans-missing-border: #fcd34d; + --trans-missing-accent: #d97706; + --trans-missing-placeholder: #92400e; + /* Status badges */ + --badge-draft-bg: #f1f5f9; + --badge-draft-fg: #64748b; + --badge-active-bg: rgba(5, 150, 105, 0.12); + --badge-active-fg: #047857; + --badge-archived-bg: rgba(217, 119, 6, 0.12); + --badge-archived-fg: #b45309; + --badge-in-progress-bg: var(--primary-subtle); + --badge-in-progress-fg: var(--primary-text); + --badge-pending-bg: var(--badge-draft-bg); + --badge-pending-fg: var(--badge-draft-fg); + --badge-admin-bg: rgba(109, 40, 217, 0.1); + --badge-admin-fg: #6d28d9; + --badge-supervisor-bg: rgba(190, 24, 93, 0.1); + --badge-supervisor-fg: #be185d; + --badge-coach-bg: rgba(22, 163, 74, 0.1); + --badge-coach-fg: #15803d; + --badge-you-bg: var(--primary-subtle); + --badge-you-fg: var(--primary-text); + --badge-warn-bg: rgba(217, 119, 6, 0.12); + --badge-warn-fg: #b45309; + --badge-q-bg: var(--primary-subtle); + --badge-q-fg: var(--primary-text); + --badge-opt-bg: #f1f5f9; + --badge-opt-fg: #475569; + --badge-app-bg: rgba(22, 163, 74, 0.1); + --badge-app-fg: #15803d; + --badge-str-bg: rgba(109, 40, 217, 0.1); + --badge-str-fg: #6d28d9; + --badge-branch-bg: rgba(109, 40, 217, 0.1); + --badge-branch-fg: #6d28d9; + --lang-chip-fixed-bg: var(--primary-subtle); + --lang-chip-fixed-border: var(--primary-border); + --toast-success-bg: #ecfdf5; + --toast-success-fg: #166534; + --toast-success-border: #86efac; + --toast-error-bg: #fef2f2; + --toast-error-fg: #991b1b; + --toast-error-border: #fca5a5; + --toast-info-bg: #ddf4ef; + --toast-info-fg: #0f766e; + --toast-info-border: #99d5c9; + --save-flash-bg: var(--primary-subtle); + --progress-track: #e2e8f0; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 20px 0; +} + +pre, code { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; +} +pre { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + color: var(--text); +} +code { + background: var(--surface-raised); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.9em; +} + +/* Layout */ +.layout { + display: flex; + min-height: 100vh; + position: relative; + isolation: isolate; +} + +/* Ambient backdrop (logged-in app shell — matches login atmosphere) */ +body.logged-in:not(.login-active) .layout::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 85% 65% at 18% 22%, var(--atmosphere-blue), transparent 62%), + radial-gradient(ellipse 60% 50% at 82% 78%, var(--atmosphere-indigo), transparent 58%), + var(--bg); +} +body.logged-in:not(.login-active) .layout::after { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: radial-gradient(ellipse 100% 90% at 55% 40%, #000 15%, transparent 72%); +} + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + background: var(--surface); + backdrop-filter: blur(14px); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + box-shadow: 2px 0 16px rgba(15, 23, 42, 0.06); +} +.sidebar-brand { + position: relative; + padding: 20px 20px 20px 24px; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 1.05rem; + line-height: 1.35; + letter-spacing: -0.02em; + color: var(--text); + background: linear-gradient(180deg, rgba(15, 118, 110, 0.06) 0%, transparent 100%); +} +.sidebar-brand::before { + content: ''; + position: absolute; + left: 0; + top: 18px; + bottom: 18px; + width: 3px; + border-radius: 0 2px 2px 0; + background: var(--accent-gradient); + box-shadow: 0 0 8px rgba(15, 118, 110, 0.25); +} +.sidebar-brand small { + display: block; + font-weight: 600; + font-size: .75rem; + color: var(--primary-text); +} +.sidebar-nav { + flex: 1; + padding: 12px 0; + list-style: none; +} +.sidebar-nav a { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + margin: 0 8px; + border-radius: 6px; + color: var(--text-secondary); + text-decoration: none; + font-size: .9rem; + transition: background .15s, color .15s, box-shadow .15s; +} +.sidebar-nav a:hover { + background: var(--surface-hover); + color: var(--text); +} +.sidebar-nav a.active { + background: var(--primary-subtle); + color: var(--primary-text); + box-shadow: inset 3px 0 0 var(--primary); + border-radius: 0 6px 6px 0; + margin-left: 0; + padding-left: 17px; +} +.sidebar-nav a svg { + width: 18px; + height: 18px; + flex-shrink: 0; + transition: color 0.15s, filter 0.15s; +} +.sidebar-nav a.active svg { + color: var(--primary); + filter: drop-shadow(0 0 4px rgba(15, 118, 110, 0.2)); +} +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); + font-size: .85rem; + background: var(--surface-muted); +} +.sidebar-footer .user-info { + color: var(--text-secondary); + margin-bottom: 8px; +} + +/* Main content */ +.main-content { + position: relative; + z-index: 1; + margin-left: var(--sidebar-width); + flex: 1; + padding: 28px 32px; + min-width: 0; +} + +/* Cards */ +.card { + position: relative; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg), var(--shadow-inset); + padding: 24px; + transition: border-color 0.2s, box-shadow 0.2s; +} +body.logged-in .card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + background: var(--shine-line); + pointer-events: none; +} +body.logged-in .card > h3 { + margin: 0 0 14px; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); + background: linear-gradient(90deg, var(--text) 0%, var(--text-secondary) 120%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Elevated panels & inputs (login visual language) */ +body.logged-in :is( + .question-item, + .inline-form-card, + .trans-lang-panel, + .trans-top-bar, + .trans-sheet, + .lang-manager, + .condition-editor, + .scoring-profile-card, + .scoring-profile-picker, + .coach-client-group, + .client-qn-panel, + .table-wrapper, + .trans-table-wrapper, + .trans-list, + .pending-option-item, + .translations-panel, + .coach-recent-panel, + .client-detail-panel, + .option-item +) { + border-radius: var(--radius-lg); + box-shadow: var(--shadow), var(--shadow-inset); +} + +body.logged-in .table-wrapper, +body.logged-in .trans-table-wrapper { + border: 1px solid var(--border); + background: var(--surface); +} + +body.logged-in :is( + .category-german-input, + .coach-supervisor-select, + .type-select, + .trans-select, + .lang-add-code, + .lang-add-name, + .trans-search-input, + .followup-note-input, + .condition-json-fallback, + .scoring-profile-picker input, + .scoring-profile-picker select, + .value-score-pts, + .translation-row input[type="text"], + .trans-list-item .trans-cell-input +) { + background: var(--surface-input); + border-radius: var(--radius); +} + +body.logged-in .btn-home { + border-radius: var(--radius); + background: var(--surface-input); + box-shadow: var(--shadow); +} +body.logged-in .btn-home:hover { + background: var(--surface-hover); + border-color: var(--primary-border); + box-shadow: var(--accent-glow); +} +body.logged-in .btn-home svg { + transition: color 0.15s; +} +body.logged-in .btn-home:hover svg { + color: var(--primary-text); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + font-size: .875rem; + font-weight: 500; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-input); + color: var(--text); + cursor: pointer; + transition: background .15s, box-shadow .15s; + text-decoration: none; + line-height: 1.4; +} +.btn:hover:not(.btn-primary):not(.btn-danger) { + background: var(--surface-hover); + box-shadow: var(--shadow); +} +.btn-primary { + background: var(--accent-gradient); + color: var(--text-on-primary); + border-color: rgba(15, 118, 110, 0.35); + box-shadow: 0 1px 2px rgba(16, 42, 39, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.2); +} +.btn-primary:hover { + background: linear-gradient(135deg, #0f766e 0%, #0b5f58 45%, #059669 100%); + border-color: rgba(11, 95, 88, 0.45); + box-shadow: var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.22); +} +.btn-danger { + background: var(--danger-subtle); + color: var(--danger-text); + border-color: var(--danger-border); +} +.btn-danger:hover { + background: var(--danger-subtle-hover); + border-color: var(--danger); + color: var(--danger-hover); + box-shadow: 0 0 0 1px var(--danger-border); +} +.btn-danger:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--danger-focus-ring); +} +.btn-sm { padding: 4px 10px; font-size: .8rem; } +.btn-block { width: 100%; justify-content: center; } +.btn-icon { + padding: 6px; + border: none; + background: transparent; + color: var(--text-secondary); +} +.btn-icon:hover:not(.btn-icon-danger) { + background: var(--surface-hover); + border-radius: 4px; + color: var(--text); +} +.btn-icon-danger { + color: var(--danger-text); +} +.btn-icon-danger:hover { + background: var(--danger-subtle); + color: var(--danger-hover); + border-radius: 4px; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} +.form-group label { + display: block; + font-size: .85rem; + font-weight: 500; + margin-bottom: 4px; + color: var(--text-secondary); +} +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: .9rem; + font-family: var(--font); + background: var(--surface-input); + color: var(--text); + transition: border-color .15s, box-shadow .15s; +} +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} +.form-row { + display: flex; + gap: 12px; +} +.form-row .form-group { flex: 1; } + +/* Badges */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: .75rem; + font-weight: 600; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: .03em; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} +.badge-draft { background: var(--badge-draft-bg); color: var(--badge-draft-fg); } +.badge-active { background: var(--badge-active-bg); color: var(--badge-active-fg); } +.badge-archived { background: var(--badge-archived-bg); color: var(--badge-archived-fg); } + +.retired-questions-panel summary { + cursor: pointer; + font-weight: 600; + margin-bottom: 8px; +} +.retired-question-item { + opacity: 0.85; +} +.retired-question-item .question-header { + cursor: default; +} +.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); } +.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); } +.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); } +.badge-activity-app_sync { background: var(--badge-coach-bg); color: var(--badge-coach-fg); } +.badge-activity-app_change { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); } +.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); } +.badge-activity-web_export { background: var(--badge-admin-bg); color: var(--badge-admin-fg); } +.badge-activity-api_error { background: var(--badge-archived-bg); color: var(--badge-archived-fg); } +.security-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 14px 20px; + margin-bottom: 16px; +} +.security-settings-grid .form-group { + margin-bottom: 0; +} +.revoke-confirm-input { + width: 100%; + max-width: 360px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + font-family: var(--font-mono, ui-monospace, monospace); +} +.user-actions-cell { + white-space: nowrap; +} +.user-actions-cell .btn + .btn { + margin-left: 6px; +} +.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; } +.activity-log-card .filter-bar label { + margin: 0; + line-height: 1.2; +} +.activity-log-detail { + font-size: 0.8rem; + max-width: 280px; + word-break: break-word; +} +table.data-table.activity-log-table th, +table.data-table.activity-log-table td { + vertical-align: top; + line-height: 1.45; +} +table.data-table.activity-log-table .activity-log-col-time, +table.data-table.activity-log-table .activity-log-col-meta { + white-space: nowrap; +} +table.data-table.activity-log-table .activity-log-col-kind { + white-space: nowrap; +} +table.data-table.activity-log-table .activity-log-col-route code { + display: inline-block; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} +table.data-table.activity-log-table code { + font-size: 0.8rem; + line-height: 1.45; + vertical-align: top; +} +table.data-table.activity-log-table .badge { + vertical-align: top; +} +.activity-log-changes-cell { + min-width: 220px; + max-width: 420px; +} +.activity-log-subject, +.activity-log-actor { + font-size: 0.85rem; +} +.activity-log-subject strong, +.activity-log-actor strong { + color: var(--text); +} +.activity-log-role { + font-weight: 400; + color: var(--text-secondary); + font-size: 0.78rem; +} +.activity-log-error { + margin-top: 6px; + font-size: 0.8rem; + color: var(--danger); + line-height: 1.35; +} +.activity-change-raw { + font-size: 0.72rem; + color: var(--text-secondary); + margin-top: 2px; + font-family: var(--font-mono, ui-monospace, monospace); + word-break: break-all; +} +.activity-changes-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.activity-changes-table th { + text-align: left; + font-weight: 600; + color: var(--text-secondary); + padding: 3px 10px 3px 0; + vertical-align: top; + white-space: nowrap; + line-height: 1.45; +} +.activity-changes-table td { + padding: 3px 0; + word-break: break-word; + color: var(--text); + vertical-align: top; + line-height: 1.45; +} +.activity-changes-table tr + tr th, +.activity-changes-table tr + tr td { + border-top: 1px solid var(--border); + padding-top: 4px; +} + +/* Page header */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; + flex-wrap: wrap; + gap: 12px; +} +.page-header-start { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.page-header-titles { + min-width: 0; +} +body.logged-in .page-header-titles { + position: relative; + padding-left: 14px; +} +body.logged-in .page-header-titles::before { + content: ''; + position: absolute; + left: 0; + top: 3px; + bottom: 3px; + width: 3px; + border-radius: 2px; + background: var(--accent-gradient); + box-shadow: 0 0 8px rgba(15, 118, 110, 0.2); +} +.page-header h1 { + font-size: 1.5rem; + font-weight: 700; + margin: 0; + letter-spacing: -0.02em; +} +.page-header .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.btn-home { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + min-width: 40px; + padding: 0; + flex-shrink: 0; +} +.btn-home svg { + width: 20px; + height: 20px; +} + +.user-actions-cell { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.mobile-logout-btn, +.mobile-change-password-btn { + display: none; + position: fixed; + top: 12px; + z-index: 150; + box-shadow: var(--shadow); +} +.mobile-logout-btn { + right: 12px; +} +.mobile-change-password-btn { + right: 96px; +} +.coach-supervisor-select { + max-width: 100%; + min-width: 160px; + font-size: 0.85rem; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-input); + color: var(--text); +} + +/* Questionnaire dashboard root (not .q-grid — avoids grid layout on nested cards) */ +.q-dashboard-root { + display: block; + width: 100%; +} + +/* Questionnaire dashboard — category sections with horizontal card rows */ +.q-dashboard-groups { + display: flex; + flex-direction: column; + gap: 28px; + width: 100%; +} + +.q-category-group { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + min-width: 0; +} + +.q-category-title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: -0.01em; + padding-bottom: 4px; + padding-left: 12px; + border-bottom: 1px solid var(--border); + position: relative; +} +.q-category-title::before { + content: ''; + position: absolute; + left: 0; + top: 0.15em; + width: 4px; + height: 1em; + border-radius: 2px; + background: var(--accent-gradient-soft); +} + +.q-category-title .category-title-input { + font: inherit; + color: inherit; + letter-spacing: inherit; + width: 100%; + max-width: 480px; +} + +.category-german-input { + width: 100%; + min-width: 140px; + max-width: 320px; + padding: 6px 10px; + font-size: .9rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: inherit; +} + +.category-german-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-subtle); +} + +.category-german-input:disabled { + opacity: .6; +} + +/* Category rows: horizontal flow, wrap when the line is full */ +.q-category-grid { + display: flex; + flex-flow: row wrap; + align-items: stretch; + align-content: flex-start; + gap: 16px; + width: 100%; + min-width: 0; + margin: 0; +} + +.q-category-grid > .q-card { + flex: 0 0 300px; + width: 300px; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +/* Questionnaire card grid (legacy / other pages) */ +.q-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; +} + +.q-card .drag-handle { + margin-right: 6px; + cursor: grab; + color: var(--text-secondary); +} +.q-card { + position: relative; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + box-shadow: var(--shadow), var(--shadow-inset); + transition: box-shadow .2s, transform .2s, border-color .2s; + cursor: pointer; +} +.q-card::after { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 2px; + background: var(--accent-gradient-soft); + opacity: 0; + transition: opacity 0.2s; +} +.q-card:hover { + box-shadow: var(--shadow-lg), var(--accent-glow); + border-color: color-mix(in srgb, var(--primary-border) 60%, var(--border)); + transform: translateY(-3px); +} +.q-card:hover::after { + opacity: 1; +} +.q-card-header { + display: flex; + align-items: start; + justify-content: space-between; + margin-bottom: 12px; +} +.q-card-title { + font-size: 1.05rem; + font-weight: 600; +} +.q-card-meta { + display: flex; + gap: 16px; + color: var(--text-secondary); + font-size: .85rem; +} +.q-card-actions { + display: flex; + gap: 6px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +/* Tables — scroll inside wrapper so sticky headers stay visible */ +.table-wrapper { + overflow: auto; + max-height: min(72vh, calc(100vh - 220px)); + -webkit-overflow-scrolling: touch; +} +table.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: .875rem; +} +table.data-table th, +table.data-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); +} +table.data-table th { + font-weight: 600; + color: var(--text-secondary); + font-size: .8rem; + text-transform: uppercase; + letter-spacing: .04em; + background: var(--table-header-bg); + position: sticky; + top: 0; + z-index: 2; + box-shadow: 0 1px 0 var(--border); +} +table.data-table tbody tr { + transition: background 0.12s ease; +} +table.data-table tbody tr:nth-child(even) td { + background: var(--table-stripe-bg); +} +table.data-table tbody tr:nth-child(even) td.sticky-col { + background: color-mix(in srgb, var(--table-stripe-bg) 80%, var(--surface)); +} +table.data-table tr:hover td { + background: var(--row-hover-bg); +} +table.data-table tbody tr:hover td.sticky-col { + background: color-mix(in srgb, var(--row-hover-bg) 85%, var(--surface)); +} +table.data-table tbody tr:hover td:first-child:not(.sticky-col) { + box-shadow: inset 3px 0 0 var(--primary); +} +table.data-table .sticky-col { + position: sticky; + left: 0; + z-index: 1; + background: var(--surface); + box-shadow: 2px 0 4px var(--sticky-shadow); +} +table.data-table thead th.sticky-col { + z-index: 5; + top: 0; + background: var(--table-header-bg); +} +th.sortable { cursor: pointer; user-select: none; } +th.sortable::after { content: ' \2195'; opacity: .4; } +th.sort-asc::after { content: ' \2191'; opacity: 1; } +th.sort-desc::after { content: ' \2193'; opacity: 1; } + +/* Accordion / question list */ +.question-list { + list-style: none; +} +.question-item { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 8px; + background: var(--surface); + box-shadow: var(--shadow), var(--shadow-inset); +} +.question-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + cursor: pointer; + user-select: none; +} +.question-header:hover { background: var(--row-hover-bg); } +.drag-handle { + cursor: grab; + color: var(--text-secondary); + font-size: 1.1rem; + line-height: 1; +} +.drag-handle:active { cursor: grabbing; } +.question-header .q-text { + flex: 1; + font-weight: 500; +} +.question-header .q-type { + font-size: .8rem; + color: var(--text-secondary); + background: var(--surface-raised); + padding: 2px 8px; + border-radius: 4px; +} +.question-body { + display: none; + padding: 0 16px 16px 42px; +} +.question-item.open { + border-color: color-mix(in srgb, var(--primary-border) 45%, var(--border)); + box-shadow: var(--shadow), 0 0 0 1px color-mix(in srgb, var(--primary-border) 30%, transparent); +} +.question-item.open .question-body { + display: block; +} +.question-item.open .question-header { + background: color-mix(in srgb, var(--primary-subtle) 40%, transparent); +} +.question-item.open .chevron { + transform: rotate(90deg); +} +.chevron { + transition: transform .2s; + font-size: .8rem; + color: var(--text-secondary); +} + +/* Answer options inside a question */ +.option-list { + list-style: none; + margin-top: 8px; +} +.option-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 4px; + background: var(--surface-muted); + font-size: .875rem; +} +.option-item .opt-text { flex: 1; } +.option-item .opt-points { + font-weight: 600; + color: var(--primary-text); + font-size: .8rem; + background: var(--primary-subtle); + padding: 1px 6px; + border-radius: 4px; +} + +/* Translations panel */ +.translations-panel { + margin-top: 12px; + padding: 12px; + background: var(--surface-muted); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow), var(--shadow-inset); +} +.translations-panel h4 { + font-size: .85rem; + margin-bottom: 8px; + color: var(--text-secondary); +} +.translation-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} +.translation-row input[type="text"] { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: .85rem; +} +.translation-row .lang-code { + font-weight: 600; + font-size: .8rem; + width: 30px; + text-transform: uppercase; + color: var(--text-secondary); +} + +/* Inline edit */ +.inline-edit { + border: 1px solid transparent; + background: transparent; + padding: 2px 4px; + font: inherit; + border-radius: 4px; + width: 100%; + transition: border-color .15s; +} +.inline-edit:hover { border-color: var(--border); } +.inline-edit:focus { + outline: none; + border-color: var(--primary); + background: var(--surface); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +/* Login */ +body.login-active .main-content { + margin-left: 0; + padding: 0; + min-height: 100vh; +} + +.login-screen { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 48px 32px; + overflow: hidden; + isolation: isolate; +} + +.login-screen__backdrop { + position: absolute; + inset: 0; + z-index: 0; + background: + linear-gradient(135deg, rgba(221, 244, 239, 0.78) 0%, rgba(243, 247, 246, 0.96) 48%, rgba(231, 245, 241, 0.72) 100%), + var(--bg); +} + +.login-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(100, 116, 139, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(100, 116, 139, 0.08) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), transparent 92%); +} + +.login-screen__content { + position: relative; + z-index: 1; + width: 100%; + max-width: 980px; +} + +.login-brand { + margin-bottom: 26px; +} + +.login-brand__title { + font-size: 1.75rem; + font-weight: 750; + line-height: 1.25; + letter-spacing: -0.02em; + margin-bottom: 6px; + color: var(--text); +} + +.login-brand__subtitle { + font-size: 0.875rem; + color: var(--primary-text); + font-weight: 600; +} + +.login-card { + position: relative; + overflow: hidden; + min-height: 360px; + padding: 30px 32px 32px; + border-color: rgba(207, 225, 221, 0.9); + border-radius: 16px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 18px 50px rgba(16, 42, 39, 0.10), 0 2px 8px rgba(16, 42, 39, 0.06); +} +.login-card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 2px; + background: var(--accent-gradient); + opacity: 0.75; +} + +.login-card__heading { + font-size: 1.25rem; + font-weight: 750; + margin-bottom: 6px; +} + +.login-card__lead { + font-size: 0.925rem; + color: var(--text-secondary); + margin-bottom: 26px; +} + +.login-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: stretch; +} + +.login-options .login-card { + min-width: 0; +} + +.login-options .login-card[hidden] { + display: none; +} + +.login-card--sso { + display: flex; + flex-direction: column; +} + +.login-card--sso .login-sso { + margin-top: 28px; +} + +.login-sso__logo { + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + margin-bottom: 16px; + padding: 0 8px; +} + +.login-sso__logo img { + display: block; + max-width: 100%; + max-height: 90px; + object-fit: contain; +} + +.login-sso__logo span { + display: none; + color: var(--text); + font-size: 1rem; + font-weight: 700; + text-align: center; +} + +.login-sso__logo--fallback span { + display: block; +} + +.login-form .form-group { + margin-bottom: 18px; +} + +.login-sso { + margin-top: 0; +} + +.login-sso__button { + min-height: 50px; + background: #ffffff; + border-color: rgba(15, 118, 110, 0.34); + color: var(--primary-text); + font-weight: 600; +} + +.login-sso__button:disabled { + background: #f8fafc; + border-color: rgba(148, 163, 184, 0.36); + color: #64748b; + cursor: default; + box-shadow: none; +} + +.login-form .btn-primary { + min-height: 50px; + margin-top: 4px; + font-size: 0.95rem; +} + +.login-alert { + margin-top: 12px; + padding: 10px 12px; + border-radius: var(--radius); + font-size: 0.85rem; + line-height: 1.45; +} +.login-alert--error { + color: var(--danger-text); + background: var(--danger-subtle); + border: 1px solid var(--danger-border); +} + +.login-card__footnote { + margin-top: 22px; + text-align: center; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.error-text { color: var(--danger); font-size: .85rem; margin-top: 8px; } + +@media (max-width: 480px) { + .login-screen { + padding: 20px 16px; + align-items: flex-start; + padding-top: 48px; + } + + .login-options { + grid-template-columns: 1fr; + } + + .login-card { + min-height: 0; + padding: 26px 22px; + } + + .login-brand__title { + font-size: 1.45rem; + } + +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + .q-card:hover, + .home-kpi-link:hover, + .home-link-card:hover { + transform: none; + } +} + +/* Toast */ +#toastContainer { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; +} +.toast { + padding: 12px 16px 12px 14px; + border-radius: var(--radius-lg); + font-size: .875rem; + font-weight: 500; + line-height: 1.45; + box-shadow: var(--shadow-lg), 0 8px 24px rgba(15, 23, 42, 0.1); + opacity: 0; + transform: translateX(40px); + transition: opacity .3s, transform .3s; + max-width: 360px; + border: 1px solid var(--border); + border-left-width: 4px; + background: var(--surface-raised); + color: var(--text); +} +.toast.show { opacity: 1; transform: translateX(0); } +.toast-success { + background: var(--toast-success-bg); + color: var(--toast-success-fg); + border-color: var(--toast-success-border); + border-left-color: var(--success); +} +.toast-error { + background: var(--toast-error-bg); + color: var(--toast-error-fg); + border-color: var(--toast-error-border); + border-left-color: var(--danger); +} +.toast-info { + background: var(--toast-info-bg); + color: var(--toast-info-fg); + border-color: var(--toast-info-border); + border-left-color: var(--primary); +} + +/* Filter bar */ +.filter-bar { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; +} +.filter-bar select, +.filter-bar input { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: .85rem; + background: var(--surface-input); + color: var(--text); +} +.filter-bar input[type="search"], +.filter-bar input[type="text"].filter-search { + min-width: 180px; +} +.pagination-bar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; + font-size: .85rem; + color: var(--text-secondary); +} +.pagination-bar .btn { + min-width: 2.5rem; +} +.data-toolbar-hint { + font-size: .8rem; + color: var(--text-secondary); +} +#qdb-table th.col-key { + font-size: .75rem; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + max-width: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#qdb-table thead tr:nth-child(1) th[data-id] { + font-size: .7rem; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 56px 24px; + color: var(--text-secondary); + border: 1px dashed color-mix(in srgb, var(--border) 80%, var(--primary-border)); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--surface-muted) 70%, transparent); +} +.empty-state svg { + margin-bottom: 16px; + opacity: .5; + color: var(--primary-text); +} +.empty-state h3 { margin-bottom: 8px; color: var(--text); } + +/* Dragging */ +.question-item.dragging { + opacity: .5; + border-style: dashed; +} +.option-item.dragging { + opacity: .5; + border-style: dashed; +} + +/* Loading spinner */ +.spinner { + display: inline-block; + width: 26px; + height: 26px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-right-color: #0e7490; + border-radius: 50%; + animation: spin .7s linear infinite; + box-shadow: 0 0 10px rgba(15, 118, 110, 0.12); +} +#app > .spinner, +.main-content > .spinner, +[id$="Content"] > .spinner:only-child, +[id$="Root"] > .spinner:only-child { + display: block; + margin: 56px auto; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Responsive */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + transition: transform .3s; + } + .sidebar.open { transform: translateX(0); } + .main-content { margin-left: 0; padding: 16px; } + .q-grid { grid-template-columns: 1fr; } + body.logged-in .mobile-logout-btn, + body.logged-in .mobile-change-password-btn { + display: inline-flex; + } + body.logged-in .page-header { + padding-right: 200px; + } +} + +@media (max-width: 520px) { + .q-category-grid > .q-card { + flex: 1 1 100%; + width: 100%; + } +} + +/* Role badges */ +.badge-admin { background: var(--badge-admin-bg); color: var(--badge-admin-fg); } +.badge-supervisor { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); } +.badge-coach { background: var(--badge-coach-bg); color: var(--badge-coach-fg); } +.badge-you { background: var(--badge-you-bg); color: var(--badge-you-fg); font-size: .7rem; } + +/* Dashboard — category keys panel */ +.category-keys-table code { + font-size: .8rem; +} +table.data-table tbody tr.row-orphan-category td { + background: color-mix(in srgb, var(--surface-hover) 60%, transparent); +} + +/* Assignment page — panels fill remaining viewport; lists scroll inside */ +.main-content:has(.assign-page) { + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; +} + +.main-content:has(.assign-page) .page-header { + flex-shrink: 0; +} + +.main-content:has(.assign-page) #assignContent { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.assign-page { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + gap: 16px; +} + +.assign-layout { + display: grid; + grid-template-columns: minmax(260px, min(32vw, 380px)) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + gap: 16px; + align-items: stretch; + flex: 1 1 auto; + min-height: 0; +} + +.assign-layout > .assign-coaches, +.assign-layout > .assign-clients-card { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + height: 100%; + max-height: 100%; + min-height: 0; +} + +.assign-coaches .assign-panel-body { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.assign-panel-header { + flex-shrink: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); +} + +.assign-panel-header h3 { + margin: 0 0 10px; + font-size: 1rem; +} + +.assign-panel-header .filter-bar { + margin-bottom: 0; +} + +.assign-panel-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + -webkit-overflow-scrolling: touch; + padding: 6px 8px 10px; +} + +.assign-clients-toolbar { + flex-shrink: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 12px; +} + +.assign-clients-toolbar-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.assign-clients-toolbar-top h3 { + margin: 0; + font-size: 1rem; + line-height: 1.4; +} + +.assign-clients-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.assign-clients-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.assign-clients-filters .filter-search { + flex: 1 1 200px; + min-width: 160px; + max-width: 100%; +} + +.assign-clients-filters select { + flex: 0 1 auto; + min-width: 140px; + max-width: 100%; +} + +.assign-table-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.assign-table-panel .table-wrapper { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow: auto; + box-sizing: border-box; +} + +.assign-panel-footer, +.assign-clients-footer { + flex-shrink: 0; + padding: 10px 16px; + border-top: 1px solid var(--border); + background: var(--surface); +} + +.assign-panel-footer .pagination-bar, +.assign-clients-footer .pagination-bar { + margin-top: 0; +} + +.assign-clients-footer .pagination-bar + .selection-info { + margin-top: 8px; +} + +.assign-action-card { + flex-shrink: 0; + padding: 14px 16px; +} + +.assign-action-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + margin-top: 10px; +} + +.assign-action-row select { + flex: 1 1 220px; + min-width: 180px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: inherit; +} + +.assign-action-card h4 { + margin: 0; + font-size: .95rem; +} + +@media (max-width: 1100px) { + .assign-layout { + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + } +} + +@media (max-width: 900px) { + .assign-layout { + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 38%) minmax(0, 1fr); + } +} + +/* Coach list */ +.coach-list { + list-style: none; + margin: 0; + padding: 0; +} + +.coach-list-item { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: background .15s, border-color .15s; + display: flex; + flex-direction: column; + gap: 2px; + border: 1px solid transparent; + margin-bottom: 4px; +} + +.coach-list-item:last-child { + margin-bottom: 0; +} + +.coach-list-item:hover { + background: var(--surface-hover); +} + +.coach-list-item.active { + background: var(--primary-subtle); + border-color: var(--primary); +} + +.coach-list-empty { + padding: 20px 12px; + text-align: center; + color: var(--text-secondary); + font-size: .875rem; +} + +.coach-name { + font-weight: 600; + font-size: .9rem; + word-break: break-word; +} + +.coach-supervisor { + font-size: .8rem; + color: var(--text-secondary); + word-break: break-word; +} + +.coach-client-count { + font-size: .75rem; + color: var(--primary); + font-weight: 500; + margin-top: 2px; +} + +/* Selection info bar */ +.selection-info { + margin: 0; + padding: 8px 12px; + background: var(--primary-subtle); + border: 1px solid var(--primary-border); + border-radius: var(--radius); + font-size: .875rem; + color: var(--primary-text); + box-shadow: 0 0 0 1px rgba(15, 118, 110, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.6); +} + +/* Tabs */ +.tab-bar { + display: flex; + gap: 4px; + padding: 4px; + border-bottom: none; + margin-bottom: 20px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-inset); +} +.tab-bar button { + padding: 9px 18px; + font-size: .9rem; + font-weight: 500; + background: transparent; + border: none; + border-radius: var(--radius); + cursor: pointer; + color: var(--text-secondary); + transition: color .15s, background .15s, box-shadow .15s; +} +.tab-bar button:hover { + color: var(--text); + background: var(--surface-hover); +} +.tab-bar button.active { + color: var(--primary-text); + background: var(--primary-subtle); + box-shadow: inset 0 0 0 1px var(--primary-border), 0 1px 3px rgba(15, 118, 110, 0.08); +} + +/* Metadata form in editor */ +.meta-form { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; + margin-bottom: 24px; +} +@media (max-width: 600px) { + .meta-form { grid-template-columns: 1fr; } +} + +/* Inline form cards (add/edit question, add/edit option) */ +.inline-form-card { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + margin-bottom: 12px; + box-shadow: var(--shadow), var(--shadow-inset); +} +.inline-form-card h4 { + font-size: .9rem; + font-weight: 600; + margin-bottom: 12px; +} +.inline-form-card .form-group { margin-bottom: 10px; } + +/* Type select */ +.type-select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: .9rem; + font-family: var(--font); + background: var(--surface-input); + color: var(--text); +} +.type-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +/* Checkbox label */ +.checkbox-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: .875rem; + cursor: pointer; + user-select: none; +} +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--primary); + cursor: pointer; +} + +/* Modal dialog */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: + radial-gradient(ellipse 70% 50% at 50% 40%, rgba(15, 118, 110, 0.06), transparent 70%), + color-mix(in srgb, var(--bg) 50%, rgba(15, 23, 42, 0.35)); + backdrop-filter: blur(4px); +} +.modal-overlay[hidden] { + display: none; +} +body.modal-open { + overflow: hidden; +} +.modal-dialog { + width: 100%; + max-width: 460px; + max-height: calc(100vh - 32px); + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg), var(--shadow-inset); + padding: 24px; +} +.modal-dialog h2 { + margin: 0 0 6px; + font-size: 1.15rem; +} +.modal-dialog .modal-subtitle { + margin: 0 0 16px; + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.45; +} +.modal-dialog .modal-subtitle strong { + color: var(--text); +} +.modal-dialog .modal-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + margin-top: 20px; +} +.password-mode-fieldset { + border: none; + margin: 0 0 16px; + padding: 0; +} +.password-mode-fieldset > legend { + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 10px; + padding: 0; +} +.password-mode-options { + display: flex; + flex-direction: column; + gap: 8px; +} +.password-mode-option { + display: block; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.password-mode-option:hover { + background: var(--surface-hover); +} +.password-mode-option:has(input:checked) { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); +} +.password-mode-option input { + margin: 0 10px 0 0; + vertical-align: top; + accent-color: var(--primary); +} +.password-mode-option-title { + display: block; + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 4px; +} +.password-mode-option-desc { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.4; + margin-left: 26px; +} +.modal-hint { + font-size: 0.8rem; + color: var(--text-secondary); + margin: 0 0 14px; + line-height: 1.45; + padding: 10px 12px; + background: var(--surface-muted); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.confirm-modal-message { + margin: 0 0 16px; + line-height: 1.5; +} +.confirm-modal-message p { + margin: 0 0 10px; +} +.confirm-modal-message p:last-child { + margin-bottom: 0; +} +.confirm-modal-choices { + margin-bottom: 16px; +} +.confirm-modal--danger .confirm-modal-dialog { + border-color: color-mix(in srgb, var(--danger-border) 40%, var(--border)); +} + +/* Options builder (inside add-question form) */ +.options-builder { + border-top: 1px solid var(--border); + margin-top: 12px; + padding-top: 12px; +} + +/* Pending options list (before question is saved) */ +.pending-options-list { + list-style: none; + margin-bottom: 6px; +} +.pending-option-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 4px; + font-size: .875rem; +} +.pending-option-item .opt-text { flex: 1; } +.pending-options-empty { + font-size: .8rem; + color: var(--text-secondary); + padding: 4px 0 6px; + font-style: italic; +} + +/* Branch indicator on answer options */ +.opt-branch { + font-size: .75rem; + font-weight: 600; + color: var(--badge-branch-fg); + background: var(--badge-branch-bg); + padding: 1px 6px; + border-radius: 4px; + white-space: nowrap; +} + +/* Config section inside question forms */ +.config-section { + border-top: 1px solid var(--border); + margin-top: 12px; + padding-top: 12px; +} +.config-section .form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-family: var(--font); + resize: vertical; +} + +/* Condition details toggle */ +.condition-details summary { + list-style: none; +} +.condition-details summary::-webkit-details-marker { + display: none; +} +.condition-details summary::before { + content: '\25B6 '; + font-size: .7rem; + transition: transform .2s; + display: inline-block; + margin-right: 4px; +} +.condition-details[open] summary::before { + transform: rotate(90deg); +} + +/* ── Translations page ── */ +.trans-page { padding: 20px 24px; } +.trans-controls { + display: flex; + flex-wrap: wrap; + gap: 16px 24px; + margin-bottom: 16px; +} +.trans-control { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 200px; + max-width: 360px; + font-size: .85rem; + color: var(--text-secondary); +} +.trans-control span { font-weight: 500; } +.trans-select { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .9rem; + font-family: var(--font); + background: var(--surface); +} +.trans-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} +.trans-type-select { width: auto; min-width: 140px; flex-shrink: 0; } +.trans-lang-panel { + margin-bottom: 16px; + padding: 12px 14px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.trans-lang-panel summary { + cursor: pointer; + font-weight: 600; + font-size: .9rem; + color: var(--text); + user-select: none; +} +.trans-lang-panel[open] summary { margin-bottom: 12px; } +.trans-lang-panel .lang-chips { margin-bottom: 10px; } +.trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; } +.text-muted { color: var(--text-secondary); } +.required-mark { color: var(--danger); font-weight: 600; } +.trans-sheet { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + display: flex; + flex-direction: column; + max-height: calc(100vh - 420px); + overflow: hidden; +} +.trans-sheet-body { + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} +.trans-sheet .trans-list-header { + flex-shrink: 0; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + margin: 0; +} +.trans-list-header { + display: grid; + grid-template-columns: minmax(140px, 1.1fr) 1fr 1fr; + gap: 12px; + padding: 8px 14px; + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-secondary); + background: var(--table-header-bg); +} +.trans-list-header-sticky { + position: sticky; + top: 0; + z-index: 2; + box-shadow: 0 1px 0 var(--border); +} +.trans-top-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 20px; + margin-bottom: 12px; + padding: 12px 14px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.trans-top-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 20px; + font-size: .88rem; + color: var(--text-secondary); +} +.trans-top-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.trans-save-status { + font-size: .82rem; + color: var(--text-secondary); +} +.trans-save-status[data-state="saving"] { color: var(--primary); } +.trans-save-status[data-state="error"] { color: var(--danger); } +.trans-toolbar-field { + min-width: 180px; + max-width: 280px; + margin: 0; +} +.trans-empty-hint { margin-top: 12px; } +.trans-qn-block { + margin-bottom: 28px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.trans-qn-block:last-child { border-bottom: none; margin-bottom: 0; } +.trans-qn-hidden { display: none !important; } +.trans-qn-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + margin: 0 0 14px; +} +.trans-group { + margin-bottom: 18px; +} +.trans-group-hidden { display: none !important; } +.trans-group-title { + font-size: .8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-secondary); + margin: 0 0 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} +.trans-groups-flat { display: block; } +.trans-group-rows { display: block; } +.trans-scope-block .trans-group-details:first-child { border-top: none; } +.trans-group-details { + border: none; + border-top: 1px solid var(--border); + border-radius: 0; + margin: 0; + background: transparent; +} +.trans-group-details.trans-group-hidden { display: none !important; } +.trans-group-summary { + cursor: pointer; + font-weight: 600; + font-size: .8rem; + padding: 10px 14px; + list-style: none; + user-select: none; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: .04em; + background: var(--surface-muted); +} +.trans-group-summary::-webkit-details-marker { display: none; } +.trans-group-summary::before { + content: '▸ '; + color: var(--text-secondary); +} +.trans-group-details[open] > .trans-group-summary::before { + content: '▾ '; +} +.trans-qn-section { + border: none; + border-top: 1px solid var(--border); + border-radius: 0; + margin: 0; + background: transparent; +} +.trans-qn-section.trans-qn-hidden { display: none !important; } +.trans-qn-section:first-child { border-top: none; } +.trans-qn-summary { + cursor: pointer; + font-weight: 600; + font-size: .92rem; + padding: 12px 14px; + list-style: none; + user-select: none; + color: var(--text); + background: var(--surface-muted); +} +.trans-qn-summary::-webkit-details-marker { display: none; } +.trans-qn-summary::before { + content: '▸ '; + color: var(--text-secondary); +} +.trans-qn-section[open] > .trans-qn-summary::before { + content: '▾ '; +} +.trans-sheet-body-stack .trans-group-summary { + font-size: .78rem; + padding: 8px 14px 8px 22px; + background: transparent; +} +.trans-list { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.trans-list-item { + display: grid; + grid-template-columns: minmax(140px, 1.1fr) 1fr 1fr; + gap: 12px; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} +.trans-key-labels { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.trans-key-primary { + font-size: .88rem; + font-weight: 500; + color: var(--text); + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; +} +.trans-key-secondary { + font-size: .72rem; + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trans-sheet-body > .trans-scope-block .trans-group-details:last-child .trans-list-item:last-child, +.trans-sheet-body > .trans-groups-flat .trans-group-details:last-child .trans-list-item:last-child { + border-bottom: none; +} +.trans-list-item:nth-child(even) { + background: var(--table-stripe-bg); +} +.trans-list-item:hover { background: var(--row-hover-bg); } +.trans-list-item-missing { + background: var(--trans-missing-bg); + box-shadow: inset 3px 0 0 var(--trans-missing-accent); +} +.trans-list-item-missing:hover { background: var(--trans-missing-bg-hover); } +.trans-list-key { + display: flex; + align-items: flex-start; + gap: 6px; + min-width: 0; +} +.trans-key-text { + font-family: monospace; + font-size: .8rem; + line-height: 1.35; + color: var(--text-secondary); + word-break: break-word; +} +.trans-list-source { + min-width: 0; +} +.trans-source-text { + display: block; + font-size: .9rem; + line-height: 1.4; + color: var(--text); + word-break: break-word; +} +.lang-chip-fixed { + background: var(--lang-chip-fixed-bg); + border-color: var(--lang-chip-fixed-border); +} +.trans-list-item .trans-cell-input { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 6px; + padding: 8px 10px; +} +.trans-input-missing { + border-color: var(--trans-missing-border) !important; + background: var(--surface) !important; + box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.18); +} +.trans-input-missing::placeholder { + color: var(--trans-missing-placeholder); + font-style: normal; +} + +@media (max-width: 768px) { + .trans-list-header, + .trans-list-item { + grid-template-columns: 1fr; + gap: 6px; + } + .trans-list-header { display: none; } + .trans-list-item .trans-list-key::before { + content: 'Label'; + display: block; + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 2px; + } + .trans-list-item .trans-list-source::before { + content: 'German'; + display: block; + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 2px; + } + .trans-list-item .trans-cell-input[data-field="translation"] { + margin-top: 4px; + } +} + +/* ── Language manager ── */ +.lang-manager { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px 16px; + margin-bottom: 20px; +} +.lang-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} +.lang-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 4px 10px 4px 12px; + font-size: .85rem; +} +.lang-chip strong { color: var(--primary); } +.lang-chip-name { color: var(--text-secondary); } +.lang-chip-remove { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.1rem; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} +.lang-chip-remove:hover { color: var(--danger); } +.lang-add-row { + display: flex; + gap: 8px; + align-items: center; +} +.lang-add-code { + width: 80px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .85rem; +} +.lang-add-name { + width: 160px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .85rem; +} + +/* ── Translations toolbar ── */ +.trans-toolbar { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 14px; + flex-wrap: wrap; +} +.trans-search-wrap { flex: 1; min-width: 200px; } +.trans-search-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .9rem; + font-family: var(--font); +} +.trans-search-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} +.trans-filter-types { + display: flex; + gap: 12px; + flex-shrink: 0; +} + +/* ── Translations table ── */ +.trans-table-wrapper { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.trans-table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} +.trans-table th { + position: sticky; + top: 0; + background: var(--table-header-bg); + font-weight: 600; + text-align: left; + padding: 8px 10px; + border-bottom: 2px solid var(--border); + white-space: nowrap; + z-index: 1; +} +.trans-table td { padding: 2px 4px; } +.trans-table tbody tr { border-bottom: 1px solid var(--border); } +.trans-table tbody tr:nth-child(even) { + background: var(--table-stripe-bg); +} +.trans-table tbody tr:hover { background: var(--row-hover-bg); } +.trans-row-hidden { display: none !important; } +#tabContent-translations .trans-list, +#tabContent-translations .trans-list-header { + margin-top: 0; +} + +.col-key { min-width: 180px; } +.col-type { width: 50px; text-align: center; } +.col-lang { min-width: 160px; } + +.cell-key { + font-family: monospace; + font-size: .82rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; + padding: 6px 10px !important; +} +.cell-type { text-align: center; padding: 6px 4px !important; } + +.trans-type-badge { + display: inline-block; + font-size: .7rem; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: .3px; +} +.badge-q { background: var(--badge-q-bg); color: var(--badge-q-fg); } +.badge-opt { background: var(--badge-opt-bg); color: var(--badge-opt-fg); } +.badge-app { background: var(--badge-app-bg); color: var(--badge-app-fg); } +.badge-str { background: var(--badge-str-bg); color: var(--badge-str-fg); } + +.cell-trans { position: relative; } +.cell-missing { background: var(--trans-missing-bg); } + +.trans-cell-input { + width: 100%; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: 4px; + font-size: .85rem; + font-family: var(--font); + background: transparent; + transition: border-color .15s, background .15s; +} +.trans-cell-input:hover { + border-color: var(--border); + background: var(--surface); +} +.trans-cell-input:focus { + outline: none; + border-color: var(--primary); + background: var(--surface); + box-shadow: 0 0 0 2px var(--focus-ring); +} +.trans-cell-input::placeholder { + color: var(--trans-missing-placeholder); + font-style: normal; + font-size: .8rem; +} +.trans-cell-input:not(.trans-input-missing)::placeholder { + color: var(--text-secondary); +} +.trans-cell-input.save-flash { + animation: saveFlash .4s ease; +} +@keyframes saveFlash { + 0% { background: var(--save-flash-bg); } + 100% { background: var(--surface); } +} +.trans-list-item .trans-cell-input.save-flash { + animation: saveFlash .4s ease; +} + +/* ── Stats bar ── */ +.trans-stats { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + font-size: .82rem; + color: var(--text-secondary); +} +.trans-stats-progress { + display: flex; + align-items: center; + gap: 8px; +} +.trans-progress-bar { + width: 120px; + height: 6px; + background: var(--progress-track); + border-radius: 3px; + overflow: hidden; +} +.trans-progress-fill { + display: block; + height: 100%; + background: var(--success); + border-radius: 3px; + transition: width .3s ease; +} + +.field-hint { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 4px; +} +.field-hint code { + font-size: 0.75rem; +} + +.badge-warn { + background: var(--badge-warn-bg); + color: var(--badge-warn-fg); +} + +.opt-key { + font-size: 0.75rem; + margin-right: 8px; + color: var(--text-secondary); +} + +/* Callouts */ +.callout-warn { + margin-bottom: 16px; + padding: 12px; + background: var(--badge-warn-bg); + border-radius: 8px; + color: var(--badge-warn-fg); +} +.callout-danger { + margin-bottom: 12px; + padding: 12px; + background: var(--toast-error-bg); + border-radius: 8px; + color: var(--toast-error-fg); +} +.insights-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; +} +.page-header-sub { + margin: 6px 0 0; + font-size: 0.9rem; + color: var(--text-secondary); + font-weight: normal; +} +.home-kpi-link { + text-decoration: none; + color: inherit; + position: relative; + overflow: hidden; + transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s; +} +.home-kpi-link::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--accent-gradient); + opacity: 0.75; +} +.home-kpi-link:hover { + border-color: var(--primary-border); + background: var(--surface-hover); + transform: translateY(-3px); + box-shadow: var(--shadow-lg), var(--accent-glow); +} +.home-dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} +.home-link-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; +} +.home-link-card { + display: block; + position: relative; + padding: 14px 36px 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface-muted); + text-decoration: none; + color: inherit; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, transform 0.2s; + box-shadow: var(--shadow), var(--shadow-inset); +} +.home-link-card::after { + content: '→'; + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + font-size: 1rem; + color: var(--primary-text); + opacity: 0.35; + transition: transform 0.2s, opacity 0.2s; +} +.home-link-card:hover { + border-color: var(--primary-border); + background: var(--surface-hover); + box-shadow: var(--shadow-lg), 0 0 0 1px var(--primary-border); + transform: translateX(2px); +} +.home-link-card:hover::after { + opacity: 1; + transform: translateY(-50%) translateX(3px); +} +.home-link-title { + display: block; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 4px; +} +.home-link-desc { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.35; +} +.insights-kpi { + padding: 20px; + text-align: center; +} +.insights-kpi-value { + font-size: 2rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.03em; + background: linear-gradient(160deg, var(--text) 20%, var(--primary-text) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.insights-kpi-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 6px; +} +.insights-charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 20px; +} +.insights-chart-card h3 { + margin: 0 0 4px; + font-size: 1rem; + letter-spacing: -0.01em; +} +body.logged-in .insights-chart-card.card { + transition: border-color 0.2s, box-shadow 0.2s; +} +body.logged-in .insights-chart-card.card:hover { + border-color: var(--primary-border); + box-shadow: var(--shadow-lg), 0 0 16px rgba(15, 118, 110, 0.06); +} +.insights-chart-hint { + margin: 0 0 14px; + font-size: 0.82rem; + color: var(--text-secondary); +} +.insights-vchart { + display: flex; + align-items: flex-end; + gap: 6px; + height: 160px; + padding: 8px 4px 0; + border-bottom: 1px solid var(--border); +} +.insights-vchart-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} +.insights-vchart-bar-wrap { + flex: 1; + width: 100%; + max-width: 36px; + display: flex; + align-items: flex-end; + justify-content: center; +} +.insights-vchart-bar { + width: 100%; + min-height: 2px; + border-radius: 4px 4px 0 0; + background: linear-gradient(180deg, var(--primary-hover), var(--primary)); + transition: height 0.2s ease; +} +.insights-vchart-value { + font-size: 0.7rem; + color: var(--text-secondary); + margin-top: 4px; + line-height: 1; +} +.insights-vchart-label { + font-size: 0.65rem; + color: var(--text-secondary); + margin-top: 2px; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.insights-hchart { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} +.insights-hchart-row { + display: grid; + grid-template-columns: minmax(100px, 28%) 1fr minmax(40px, auto); + gap: 10px; + align-items: center; + font-size: 0.85rem; +} +.insights-hchart-label { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.insights-hchart-track { + height: 22px; + background: var(--surface-muted); + border-radius: 4px; + overflow: hidden; +} +.insights-hchart-fill { + height: 100%; + min-width: 2px; + border-radius: 4px; + background: linear-gradient(90deg, var(--primary), var(--primary-hover)); +} +.insights-hchart-fill--warn { + background: linear-gradient(90deg, #d97706, var(--warning)); +} +.insights-hchart-fill--muted { + background: var(--border); +} +.insights-hchart-num { + text-align: right; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.followup-note-input { + width: 100%; + min-width: 180px; + max-width: 320px; + font-family: inherit; + font-size: 0.85rem; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + resize: vertical; +} +.export-actions-cell { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.export-actions-cell .btn { + margin: 0; +} +.coach-recent-panel { + padding: 12px 8px 8px 32px; + background: var(--surface-muted); + border-radius: 8px; + margin: 4px 0 8px; +} +.coach-recent-scroll { + max-height: min(420px, 55vh); + display: flex; + flex-direction: column; + overflow: hidden; +} +.coach-recent-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + margin-bottom: 10px; + flex-shrink: 0; +} +.coach-recent-toolbar .filter-search { + flex: 1; + min-width: 180px; + max-width: 320px; +} +.coach-client-groups { + flex: 1; + overflow-y: auto; + padding-right: 4px; + margin-bottom: 8px; +} +.coach-client-group { + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 8px; + background: var(--surface); + overflow: hidden; +} +.coach-client-toggle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + width: 100%; + text-align: left; + padding: 10px 12px !important; + justify-content: flex-start; +} +.coach-client-meta { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: normal; +} +.coach-client-uploads { + padding: 0 12px 12px 32px; + border-top: 1px solid var(--border); +} +.coach-recent-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; + padding-top: 4px; + border-top: 1px solid var(--border); +} +.data-table-compact td, +.data-table-compact th { + padding: 8px 10px; + font-size: 0.85rem; +} +.btn-link { + background: none; + border: none; + padding: 0 6px 0 0; + color: var(--accent); + cursor: pointer; + min-width: auto; +} +.coach-detail-row td { + border-top: none; + padding-top: 0; +} +.clients-list-filters { + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.clients-archive-filter { + flex: 0 0 auto; + min-width: 120px; +} + +.client-actions-cell { + width: 1%; + white-space: nowrap; + vertical-align: middle; +} + +.table-row-actions { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--surface-muted); +} + +.table-row-actions .btn { + border: none; + border-radius: 0; + box-shadow: none; + margin: 0; + min-height: 30px; +} + +.table-row-actions .btn:hover { + box-shadow: none; +} + +.table-row-actions .btn + .btn { + border-left: 1px solid var(--border); +} + +.table-row-actions .archive-client-btn, +.table-row-actions .restore-client-btn { + background: var(--surface); + color: var(--text-secondary); +} + +.table-row-actions .archive-client-btn:hover, +.table-row-actions .restore-client-btn:hover { + background: var(--surface-hover); + color: var(--text); +} + +.table-row-actions .delete-client-btn { + padding-left: 12px; + padding-right: 12px; +} + +.followup-note-cell { + min-width: 200px; +} + +.followup-note-cell .followup-note-input { + width: 100%; + margin-bottom: 8px; +} + +.followup-note-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: space-between; +} + +.followup-actions-cell { + width: 1%; + white-space: nowrap; + vertical-align: middle; +} + +.client-row-archived td { + color: var(--text-secondary); +} + +.client-archived-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-secondary); + background: var(--surface-raised); + border: 1px solid var(--border); + vertical-align: middle; +} + +.client-detail-panel { + padding: 12px 8px 8px 32px; + background: var(--surface-muted); + border-radius: 8px; + margin: 4px 0 8px; +} +.client-detail-meta { + margin: 0 0 12px; + font-size: 0.85rem; + color: var(--text-secondary); +} +.client-qn-panel { + padding: 10px 8px 10px 28px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + margin-top: 8px; +} +.client-version-panel { + padding: 8px 8px 8px 28px; + margin-top: 8px; + border-left: 3px solid var(--primary-border); +} +.client-detail-row td { + border-top: none; + padding-top: 0; +} +tr.answer-changed td { + background: rgba(251, 191, 36, 0.1); +} +tr.answer-changed .answer-value-cell { + font-weight: 600; + color: var(--badge-warn-fg); +} +.client-qn-summary { + font-size: 0.82rem; + color: var(--text-secondary); + margin-left: 8px; +} + +input:disabled, +select:disabled { + background: var(--surface-muted); + color: var(--text-secondary); + cursor: not-allowed; +} + +/* Questionnaire availability condition editor */ +.condition-editor { + padding: 16px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow), var(--shadow-inset); +} +.condition-mode-panel { + margin-top: 12px; +} +.condition-requires-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; + padding: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; +} +.condition-requires-item { + margin: 0; +} +.condition-answer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} +.condition-answer-grid select, +.condition-editor .form-group > select { + width: 100%; +} +.condition-anyof-rule { + margin-bottom: 12px; + padding: 12px; +} +.condition-json-fallback { + width: 100%; + font-family: ui-monospace, monospace; + font-size: 0.85rem; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); +} +.condition-locked-message .meta-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 720px) { + .condition-locked-message .meta-form { + grid-template-columns: 1fr; + } +} + +/* Questionnaire preview tab (all-in-one) */ +.qp-shell { + display: flex; + flex-direction: column; + gap: 12px; +} +.qp-banner { + padding: 10px 14px; + background: var(--primary-subtle); + border: 1px solid var(--primary-border); + border-radius: var(--radius); + font-size: .875rem; + color: var(--primary-text); +} +.qp-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.qp-visible-count { + font-size: .875rem; + color: var(--text-secondary); +} +.qp-scroll { + padding: 0; + max-height: min(75vh, 900px); + overflow-y: auto; +} +.qp-form { + display: flex; + flex-direction: column; +} +.qp-section { + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} +.qp-section:last-child { + border-bottom: none; +} +.qp-section[hidden] { + display: none !important; +} +.qp-section-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} +.qp-section-num { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--primary-subtle); + color: var(--primary-text); + font-size: .8rem; + font-weight: 600; + flex-shrink: 0; +} +.qp-section-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: .8rem; + color: var(--text-secondary); +} +.qp-id { + font-size: .8rem; + padding: 2px 6px; + background: var(--surface-muted); + border-radius: 4px; +} +.qp-type { + font-size: .8rem; +} +.qp-question-text { + font-size: 1rem; + font-weight: 500; + margin-bottom: 14px; + line-height: 1.45; +} +.qp-note { + font-size: .875rem; + color: var(--text-secondary); + margin-bottom: 12px; + padding: 8px 12px; + background: var(--surface-muted); + border-radius: 6px; +} +.qp-note-after { margin-top: 12px; margin-bottom: 0; } +.qp-hint { + font-size: .85rem; + color: var(--text-secondary); + margin-top: 8px; +} +.qp-options { + display: flex; + flex-direction: column; + gap: 8px; +} +.qp-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: border-color .15s, background .15s; +} +.qp-option:hover { + border-color: var(--primary-border); + background: var(--surface-muted); +} +.qp-option input { flex-shrink: 0; } +.qp-select { + width: 100%; + max-width: 320px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .9rem; + background: var(--surface); + color: var(--text); +} +.qp-select-sm { max-width: 140px; } +.qp-date-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.qp-text-input { + width: 100%; + max-width: 480px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .9rem; + background: var(--surface); + color: var(--text); +} +.qp-slider { + display: flex; + align-items: center; + gap: 16px; +} +.qp-slider input[type=range] { flex: 1; max-width: 400px; } +.qp-slider-value { + min-width: 3rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.qp-form-stack { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 360px; +} +.qp-form-stack label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: .875rem; + font-weight: 500; +} +.qp-form-stack input { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .9rem; + background: var(--surface); + color: var(--text); +} +.qp-review-note { + margin-bottom: 4px; +} +.qp-review-codes .qp-readonly-field { + background: var(--bg, #f4f4f5); + color: var(--text-secondary); + cursor: default; + border-style: dashed; + user-select: all; +} +.qp-glass-table { + overflow-x: auto; + margin-top: 8px; +} +.qp-glass-table table { + width: 100%; + border-collapse: collapse; + font-size: .85rem; +} +.qp-glass-table th, +.qp-glass-table td { + border: 1px solid var(--border); + padding: 8px 10px; + text-align: center; +} +.qp-glass-table th:first-child, +.qp-glass-table td:first-child { + text-align: left; + font-weight: 500; +} +.qp-empty { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); +} + +/* Glass-scale symptom rows in editor */ +.glass-symptoms-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.glass-symptom-row { + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; +} +.glass-symptoms-empty { + padding: 12px; + color: var(--text-secondary); + font-size: .85rem; + font-style: italic; +} +.glass-score-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border); +} +.glass-score-cell label { + display: block; + font-size: .7rem; + color: var(--text-secondary); + margin-bottom: 2px; +} +.glass-score-cell input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 4px; +} +.value-score-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + max-height: 200px; + overflow-y: auto; +} +.value-score-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + font-size: .85rem; +} +.value-score-label { + min-width: 2rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.value-score-pts { + width: 4rem; + padding: 4px 6px; + border: 1px solid var(--border); + border-radius: 4px; +} + +.modal-dialog-wide { + max-width: 640px; +} + +/* Scoring profiles on questionnaire dashboard */ +#scoringProfilesPanel { + width: 100%; + margin-top: 28px; +} +.scoring-profiles-section { + margin-top: 0; +} +.scoring-profiles-card { + padding: 24px; +} +.scoring-section-title { + margin: 0 0 6px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); +} +.scoring-section-desc, +.scoring-empty-hint { + margin: 0; + font-size: .875rem; + line-height: 1.5; + color: var(--text-secondary); +} +.scoring-empty-hint { + padding: 8px 0; +} +.scoring-profiles-list { + margin-top: 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.scoring-profile-card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px; + background: var(--surface-muted); + color: var(--text); + box-shadow: var(--shadow), var(--shadow-inset); +} +.scoring-profile-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} +.scoring-profile-card-main { + min-width: 0; + flex: 1; +} +.scoring-profile-name-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.scoring-profile-name-row strong { + font-size: 1rem; + color: var(--text); +} +.scoring-profile-desc { + margin: 0 0 8px; + font-size: .875rem; + line-height: 1.45; + color: var(--text-secondary); +} +.scoring-profile-bands { + margin: 0; + font-size: .85rem; + color: var(--primary-text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +.scoring-profile-member-count { + margin: 12px 0 8px; + font-size: .8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-secondary); +} +.scoring-profile-members { + border-top: 1px solid var(--border); + padding-top: 4px; +} +.scoring-profile-card-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.scoring-profile-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} +.scoring-modal-section-title { + margin: 18px 0 0; + font-size: .95rem; + color: var(--text); +} +.scoring-profile-active-row { + margin-bottom: 12px; +} +.checkbox-inline { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: .9rem; + color: var(--text); + cursor: pointer; +} +.scoring-profile-picker { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px; + background: var(--surface-muted); + box-shadow: var(--shadow), var(--shadow-inset); +} +.scoring-profile-picker input, +.scoring-profile-picker select { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .875rem; + font-family: var(--font); + background: var(--surface); + color: var(--text); +} +.scoring-profile-picker input:focus, +.scoring-profile-picker select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} +.scoring-profile-add-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.scoring-profile-add-row select { + flex: 1; + min-width: 0; +} +.scoring-band-labels { + margin: 4px 0 8px; + min-height: 1.5rem; +} +.scoring-band-ranges-table { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} +.scoring-band-range-row { + display: grid; + grid-template-columns: 100px 1fr 1fr; + gap: 10px; + align-items: center; +} +.scoring-band-range-row.scoring-band-range-header { + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-secondary); + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} +.scoring-band-range-row input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: .875rem; + font-family: var(--font); + background: var(--surface); + color: var(--text); +} +.scoring-band-range-row input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} +.scoring-band-max-placeholder { + color: var(--text-secondary); + font-size: 1.1rem; + padding: 8px 10px; +} +.scoring-band-strips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.scoring-profile-q-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border); + color: var(--text); +} +.scoring-profile-q-row:last-child { border-bottom: none; } +.scoring-profile-q-name { + flex: 1; + min-width: 120px; + font-weight: 500; + color: var(--text); +} +.scoring-profile-q-weight { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.scoring-profile-q-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} +.sp-weight-wrap { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: .8rem; + color: var(--text-secondary); +} +.sp-weight-wrap input { + width: 72px; +} +.band-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: .75rem; + font-weight: 600; + letter-spacing: .02em; +} +.band-badge.band-green { background: #edf8f2; color: #1b5e20; border: 1px solid #b8dfc8; } +.band-badge.band-yellow { background: #fff6e7; color: #6a4a00; border: 1px solid #f5d08a; } +.band-badge.band-red { background: #fdeeee; color: #b71c1c; border: 1px solid #f5b4b8; } +.band-badge.band-none { background: #eef8f5; color: #4f5f5c; border: 1px solid #cfe1dd; } + +/* Client list — scoring profiles */ +.client-scoring-legend { + margin: 0 0 14px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-elevated); +} +.client-scoring-legend-title { + margin: 0 0 6px; +} +.client-scoring-legend-body { + margin: 0 0 10px; + line-height: 1.45; +} +.client-scoring-legend-body .band-badge { + margin: 0 2px; + vertical-align: middle; +} +.client-scoring-legend-dl { + display: grid; + gap: 8px 16px; + margin: 0; + font-size: .8125rem; +} +@media (min-width: 640px) { + .client-scoring-legend-dl { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +.client-scoring-legend-dl dt { + font-weight: 600; + color: var(--text); + margin: 0; +} +.client-scoring-legend-dl dd { + margin: 2px 0 0; + color: var(--text-secondary); + line-height: 1.4; +} +.client-profile-scores { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; +} +.client-profile-score-block { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-elevated); + font-size: .8125rem; + line-height: 1.35; +} +.client-profile-score-block.is-incomplete { + opacity: 0.85; +} +.client-profile-score-block.has-coach-override { + border-color: rgba(251, 191, 36, 0.45); +} +.client-profile-score-block-title { + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} +.client-profile-score-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + margin-top: 4px; +} +.client-profile-score-row:first-of-type { + margin-top: 0; +} +.client-profile-score-label { + flex: 0 0 4.75rem; + color: var(--text-secondary); + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .03em; +} +.client-profile-score-total { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + font-size: .75rem; +} +.client-profile-score-total::before { + content: "total "; + opacity: 0.75; +} +.client-profile-override-hint { + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: #ca8a04; +} + +/* Coach category picker (clients list) */ +.coach-band-picker { + position: relative; + display: inline-flex; + max-width: 100%; +} +.coach-band-trigger { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: inherit; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + max-width: 100%; +} +.coach-band-trigger:hover { + border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); + background: var(--surface-hover); +} +.coach-band-picker.is-open .coach-band-trigger, +.coach-band-trigger:focus-visible { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 22%, transparent); +} +.coach-band-trigger-badge .band-badge { + margin: 0; +} +.coach-band-trigger-caret { + font-size: .65rem; + color: var(--text-secondary); + line-height: 1; + flex-shrink: 0; +} +.coach-band-picker.is-saving .coach-band-trigger { + opacity: 0.6; + pointer-events: none; +} +.coach-band-menu { + min-width: 11.5rem; + padding: 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface); + box-shadow: var(--shadow-lg); +} +.coach-band-menu-label { + margin: 0 0 8px; + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-secondary); +} +.coach-band-menu-options { + display: flex; + flex-direction: column; + gap: 4px; +} +.coach-band-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + transition: background 0.12s, border-color 0.12s; +} +.coach-band-option:hover { + background: var(--surface-hover); +} +.coach-band-option.is-selected { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); +} +.coach-band-option-agree { + margin-top: 6px; + padding-top: 10px; + border-top: 1px solid var(--border); + border-radius: 0 0 8px 8px; +} +.coach-band-option-agree-label { + font-size: .75rem; + font-weight: 600; + color: var(--text-secondary); +} +.coach-band-confirm-compare { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px 16px; + margin: 8px 0 4px; + padding: 16px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-elevated); +} +.coach-band-confirm-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + min-width: 5.5rem; +} +.coach-band-confirm-label { + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-secondary); +} +.coach-band-confirm-arrow { + font-size: 1.25rem; + color: var(--text-secondary); + line-height: 1; +} +.client-profile-score-hint, +.client-profile-score-meta { + margin: 6px 0 0; + font-size: .75rem; + color: var(--text-secondary); + line-height: 1.4; +} +.client-scoring-profiles-detail { + margin-bottom: 12px; +} +.scoring-profile-detail-card { + margin-top: 8px; +} +.client-profile-dots-cell { + min-width: 200px; + max-width: 320px; + vertical-align: top; +} +.client-profile-dots-empty { + color: var(--text-secondary); +} diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..853cf32 --- /dev/null +++ b/website/index.html @@ -0,0 +1,99 @@ + + + + + + + + NAT-AS Ressourcenbarometer + + + + +
+ + +
+ +
+
+ +
+ + + + + + diff --git a/website/js/api.js b/website/js/api.js new file mode 100644 index 0000000..b9ff151 --- /dev/null +++ b/website/js/api.js @@ -0,0 +1,214 @@ +/** + * Resolve API base for sibling (/website + /api) or subpath (/prefix/ + /prefix/api) layouts. + * Override per deploy: + */ +function resolveApiBase() { + if (typeof window !== 'undefined' && window.QDB_API_BASE) { + return String(window.QDB_API_BASE).replace(/\/$/, ''); + } + const fromRelative = new URL('../api', window.location.href); + const relPath = fromRelative.pathname.replace(/\/$/, ''); + const pageDir = window.location.pathname.replace(/\/[^/]*$/, '/') || '/'; + const mount = pageDir.split('/').filter(Boolean)[0]; + // Subpath deploy: /prefix/ is aliased to website/, so ../api wrongly becomes /api + if (mount && mount !== 'api' && mount !== 'website' && relPath === '/api') { + return `/${mount}/api`; + } + return relPath; +} + +const API_BASE = resolveApiBase(); + +/** Build a URL under the API base (path may include query string). */ +export function apiUrl(endpoint) { + const clean = String(endpoint).replace(/^\//, '').replace(/\.php(\?|$)/, '$1'); + return `${API_BASE}/${clean}`; +} + +function getToken() { + return localStorage.getItem('qdb_token'); +} + +const WEBSITE_ROLES = ['admin', 'supervisor']; + +let sessionCheckPromise = null; +let sessionCheckToken = null; + +/** Clear cached session validation (after logout or token change). */ +export function invalidateSessionCheck() { + sessionCheckPromise = null; + sessionCheckToken = null; +} + +/** Clear stored session and open the login page. */ +export function redirectToLogin() { + invalidateSessionCheck(); + localStorage.removeItem('qdb_token'); + localStorage.removeItem('qdb_user'); + localStorage.removeItem('qdb_role'); + window.location.hash = '#/login'; +} + +/** @returns {boolean} true if the response means the session is no longer valid */ +function isSessionInvalidResponse(res, json) { + if (res.status === 401) return true; + const errMsg = json.error?.message || (typeof json.error === 'string' ? json.error : ''); + const errCode = json.error?.code || ''; + return res.status === 401 + || res.status === 403 && ( + errCode === 'UNAUTHORIZED' + || errCode === 'PASSWORD_CHANGE_REQUIRED' + || errCode === 'FORBIDDEN' + || errMsg === 'Invalid or expired token' + || errMsg === 'Missing Bearer token' + || errMsg === 'Password change required before access' + ); +} + +/** + * Verify the stored token is still valid on the server. Redirects to login when not. + * @returns {Promise} + */ +export async function ensureValidSession() { + const token = getToken(); + if (!token) { + redirectToLogin(); + return false; + } + + if (sessionCheckPromise && sessionCheckToken === token) { + return sessionCheckPromise; + } + + sessionCheckToken = token; + sessionCheckPromise = (async () => { + try { + const data = await apiGet('session'); + if (!data.success || !data.valid) { + redirectToLogin(); + return false; + } + if (data.mustChangePassword) { + redirectToLogin(); + return false; + } + if (!WEBSITE_ROLES.includes(data.role)) { + redirectToLogin(); + return false; + } + if (data.user) localStorage.setItem('qdb_user', data.user); + if (data.role) localStorage.setItem('qdb_role', data.role); + return true; + } catch (e) { + if (e.message !== 'Session expired') { + redirectToLogin(); + } + return false; + } + })(); + + return sessionCheckPromise; +} + +async function handleAuthResponse(res) { + if (res.status !== 401 && res.status !== 403) return; + const json = await res.json().catch(() => ({})); + if (isSessionInvalidResponse(res, json)) { + redirectToLogin(); + throw new Error('Session expired'); + } + const errMsg = json.error?.message || json.error || ''; + throw new Error(errMsg || 'Permission denied'); +} + +async function apiFetch(endpoint, options = {}) { + const token = getToken(); + const headers = options.headers || {}; + headers['X-QDB-Client'] = 'web'; + if (token) headers['Authorization'] = `Bearer ${token}`; + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json; charset=UTF-8'; + } + + // Strip .php suffix for clean URLs + const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1'); + const res = await fetch(apiUrl(cleanEndpoint), { ...options, headers }); + + if (res.status === 401 || res.status === 403) { + await handleAuthResponse(res); + } + + return res; +} + +/** + * Unwrap the unified response envelope: + * {"ok": true, "data": ...} -> returns { success: true, ...data } + * {"ok": false, "error": ...} -> returns { success: false, error: message } + * Also supports legacy format for backward compat. + */ +function unwrap(json) { + if (typeof json.ok === 'boolean') { + if (json.ok) { + const d = json.data || {}; + return { success: true, ...(Array.isArray(d) ? { items: d } : d) }; + } + const err = json.error; + const msg = typeof err === 'string' ? err : (err?.message || 'Unknown error'); + return { success: false, error: msg, errorCode: typeof err === 'object' ? err?.code : null }; + } + return json; +} + +export async function apiGet(endpoint) { + const res = await apiFetch(endpoint); + return unwrap(await res.json()); +} + +export async function apiPost(endpoint, body) { + const res = await apiFetch(endpoint, { + method: 'POST', + body: JSON.stringify(body), + }); + return unwrap(await res.json()); +} + +export async function apiPut(endpoint, body) { + const res = await apiFetch(endpoint, { + method: 'PUT', + body: JSON.stringify(body), + }); + return unwrap(await res.json()); +} + +export async function apiPatch(endpoint, body) { + const res = await apiFetch(endpoint, { + method: 'PATCH', + body: JSON.stringify(body), + }); + return unwrap(await res.json()); +} + +export async function apiDelete(endpoint, body) { + const res = await apiFetch(endpoint, { + method: 'DELETE', + body: JSON.stringify(body), + }); + return unwrap(await res.json()); +} + +/** Authenticated file/download fetch (marks request as website for activity logging). */ +export async function apiDownloadFetch(url, options = {}) { + const token = getToken(); + const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(url, { ...options, headers }); + const ct = res.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) { + await handleAuthResponse(res); + } else if (res.status === 401 || res.status === 403) { + redirectToLogin(); + throw new Error('Session expired'); + } + return res; +} diff --git a/website/js/app.js b/website/js/app.js new file mode 100644 index 0000000..c5fb1a5 --- /dev/null +++ b/website/js/app.js @@ -0,0 +1,192 @@ +import { addRoute, startRouter, navigate } from './router.js'; +import { ensureValidSession, invalidateSessionCheck } from './api.js'; +import { loginPage } from './pages/login.js'; +import { homeDashboardPage } from './pages/home.js'; +import { questionnairesPage } from './pages/questionnaires.js'; +import { editorPage } from './pages/editor.js'; +import { resultsPage } from './pages/results.js'; +import { exportPage } from './pages/export.js'; +import { usersPage } from './pages/users.js'; +import { assignmentsPage } from './pages/assignments.js'; +import { clientsPage } from './pages/clients.js'; +import { translationsPage } from './pages/translations.js'; +import { devPage } from './pages/dev.js'; +import { insightsPage } from './pages/insights.js'; +import { coachesPage } from './pages/coaches.js'; +import { openChangeOwnPasswordModal } from './password-modal.js'; + +// Auth state +export function isLoggedIn() { + return !!localStorage.getItem('qdb_token'); +} +export function getRole() { + return localStorage.getItem('qdb_role') || ''; +} +export function getUser() { + return localStorage.getItem('qdb_user') || ''; +} +const WEBSITE_ROLES = ['admin', 'supervisor']; + +export function canAccessWebsite() { + return WEBSITE_ROLES.includes(getRole()); +} + +export function canEdit() { + return canAccessWebsite(); +} + +/** User-facing label for the coach role (internal key remains `coach`). */ +export function formatRoleLabel(role) { + if (role === 'coach') return 'Counselor'; + if (!role) return ''; + return role.charAt(0).toUpperCase() + role.slice(1); +} + +function clearSession() { + invalidateSessionCheck(); + localStorage.removeItem('qdb_token'); + localStorage.removeItem('qdb_role'); + localStorage.removeItem('qdb_user'); +} + +function logout() { + clearSession(); + navigate('#/login'); +} + +/** Link to home dashboard (#/). */ +export const HOME_HREF = '#/'; + +const HOME_ICON_SVG = ``; + +/** House icon button — returns to dashboard. */ +export function homeNavButton(title = 'Overview') { + return `${HOME_ICON_SVG}`; +} + +/** + * Standard page header with home button. + * @param {string} title HTML-safe title text + * @param {string} [actionsHTML] optional buttons inside .actions + * @param {{ subtitle?: string }} [opts] + */ +export function pageHeaderHTML(title, actionsHTML = '', opts = {}) { + const subtitle = opts.subtitle + ? `

${opts.subtitle}

` + : ''; + return ` + `; +} + +export function showToast(message, type = 'info') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('show')); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +function authGuard(handler) { + return async (params) => { + if (!(await ensureValidSession())) return; + return handler(params); + }; +} + +function updateNav() { + const nav = document.getElementById('mainNav'); + const userInfo = document.getElementById('userInfo'); + document.body.classList.toggle('logged-in', isLoggedIn()); + const hash = window.location.hash.slice(1) || '/'; + document.body.classList.toggle('login-active', hash === '/login' || hash.startsWith('/login?')); + if (isLoggedIn()) { + nav.style.display = ''; + const role = getRole(); + const roleLabel = formatRoleLabel(role); + userInfo.textContent = `${getUser()} (${roleLabel})`; + + // Show/hide role-gated nav items + document.querySelectorAll('[data-nav-roles]').forEach(li => { + const allowed = li.dataset.navRoles.split(' '); + li.style.display = allowed.includes(role) ? '' : 'none'; + }); + + // Highlight active nav link + document.querySelectorAll('.sidebar-nav a').forEach(a => { + const href = a.getAttribute('href').slice(1); + let active = hash === href; + if (!active && href === '/questionnaires' && hash.startsWith('/questionnaire')) { + active = true; + } + if (!active && href !== '/' && hash.startsWith(href)) { + active = true; + } + a.classList.toggle('active', active); + }); + } else { + nav.style.display = 'none'; + userInfo.textContent = ''; + } +} + +function roleGuard(roles, handler) { + return async (params) => { + if (!(await ensureValidSession())) return; + if (!roles.includes(getRole())) { navigate('#/'); return; } + return handler(params); + }; +} + +// Routes +addRoute('/login', loginPage); +addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage)); +addRoute('/questionnaires', authGuard(questionnairesPage)); +addRoute('/questionnaire/new', authGuard(editorPage)); +addRoute('/questionnaire/:id/results', authGuard(resultsPage)); +addRoute('/questionnaire/:id', authGuard(editorPage)); +addRoute('/export', authGuard(exportPage)); +addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage)); +addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage)); +addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage)); +addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage)); +addRoute('/insights', roleGuard(['admin', 'supervisor'], insightsPage)); +addRoute('/coaches', roleGuard(['admin', 'supervisor'], coachesPage)); +addRoute('/dev', roleGuard(['admin'], devPage)); + +// Nav link handling & logout +document.addEventListener('DOMContentLoaded', () => { + const bindLogout = (el) => { + if (!el) return; + el.addEventListener('click', (e) => { + e.preventDefault(); + logout(); + }); + }; + bindLogout(document.getElementById('logoutBtn')); + bindLogout(document.getElementById('mobileLogoutBtn')); + + const openOwnPassword = async () => { + if (!(await ensureValidSession())) return; + openChangeOwnPasswordModal(); + }; + document.getElementById('changePasswordBtn')?.addEventListener('click', openOwnPassword); + document.getElementById('mobileChangePasswordBtn')?.addEventListener('click', openOwnPassword); + + window.addEventListener('hashchange', updateNav); + updateNav(); + startRouter(); +}); diff --git a/website/js/client-archive.js b/website/js/client-archive.js new file mode 100644 index 0000000..35bbd66 --- /dev/null +++ b/website/js/client-archive.js @@ -0,0 +1,48 @@ +import { apiPatch } from './api.js'; +import { showToast } from './app.js'; +import { confirmAction } from './confirm-modal.js'; + +export function isClientArchived(c) { + return Number(c.archived) === 1; +} + +export async function confirmAndPatchClientArchive(clientCode, archived) { + if (!(await confirmAction({ + title: archived ? 'Archive client' : 'Restore client', + message: archived + ? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.` + : `Restore client "${clientCode}" to active lists?`, + confirmLabel: archived ? 'Archive' : 'Restore', + variant: archived ? 'danger' : 'default', + }))) return false; + + try { + await apiPatch('clients.php', { clientCode, archived }); + showToast( + archived ? `Client "${clientCode}" archived` : `Client "${clientCode}" restored`, + 'success' + ); + return true; + } catch (e) { + showToast(e.message, 'error'); + return false; + } +} + +/** + * @param {object} opts + * @param {string} opts.clientCode Escaped client code for data-code attributes + * @param {boolean} opts.archived + * @param {boolean} [opts.showDelete] + * @param {string} [opts.rawCode] Unescaped code for button labels (optional) + */ +export function clientTableActionsHTML({ clientCode, archived, showDelete = false }) { + const archiveBtn = archived + ? `` + : ``; + const deleteBtn = showDelete + ? `` + : ''; + + return `
${archiveBtn}${deleteBtn}
`; +} diff --git a/website/js/condition-editor.js b/website/js/condition-editor.js new file mode 100644 index 0000000..e900cea --- /dev/null +++ b/website/js/condition-editor.js @@ -0,0 +1,662 @@ +/** + * Questionnaire availability condition UI (same JSON schema as the Android app). + */ + +const CONDITION_MODES = [ + { value: 'always', label: 'Always available' }, + { value: 'requires', label: 'Requires other questionnaires completed' }, + { value: 'requires_answer', label: 'Requires completions + answer check' }, + { value: 'any_of', label: 'Any of these rules (OR)' }, + { value: 'custom', label: 'Custom JSON (advanced)' }, +]; + +const CHOICE_LAYOUT_TYPES = new Set([ + 'radio_question', + 'multi_check_box_question', + 'glass_scale_question', +]); + +export function defaultConditionMessageKey(questionnaireId) { + const id = (questionnaireId || 'new').replace(/^questionnaire_/, ''); + const slug = id.replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'new'; + const normalized = /^[a-zA-Z]/.test(slug) ? slug : `k_${slug}`; + return `questionnaire_unlock_${normalized}`; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function parseQuestionConfig(q) { + try { + return JSON.parse(q?.configJson || '{}'); + } catch { + return {}; + } +} + +/** Stable id stored in conditionJson.questionId (matches app endsWith check). */ +export function conditionQuestionId(q) { + const cfg = parseQuestionConfig(q); + const key = (q?.questionKey || cfg.questionKey || '').trim(); + if (key) return key; + if (q?.localId) return q.localId; + const parts = (q?.questionID || '').split('__'); + return parts[parts.length - 1] || ''; +} + +function conditionQuestionLabel(q) { + const id = conditionQuestionId(q); + const text = (q?.defaultText || '').trim(); + if (!text) return id; + const short = text.length > 48 ? `${text.slice(0, 45)}…` : text; + return `${id} — ${short}`; +} + +function questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) { + if (!questionnaireId) return []; + return questionsByQuestionnaire?.[questionnaireId] || []; +} + +function findQuestion(questionsByQuestionnaire, questionnaireId, questionId) { + if (!questionnaireId || !questionId) return null; + return questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) + .find(q => { + const cid = conditionQuestionId(q); + const local = q.localId || (q.questionID || '').split('__').pop(); + return questionId === cid || questionId === local || questionId === q.questionID; + }) || null; +} + +function questionMatchesSelection(q, selectedQuestionId) { + if (!selectedQuestionId) return false; + const cid = conditionQuestionId(q); + const local = q.localId || (q.questionID || '').split('__').pop(); + return selectedQuestionId === cid + || selectedQuestionId === local + || selectedQuestionId === q.questionID; +} + +function asRequiresList(obj) { + if (!obj) return []; + if (Array.isArray(obj.requiresCompleted)) { + return obj.requiresCompleted.filter(Boolean).map(String); + } + if (typeof obj.requiresCompleted === 'string' && obj.requiresCompleted) { + return [obj.requiresCompleted]; + } + return []; +} + +function hasQuestionCheck(obj) { + return Boolean( + obj?.questionnaire?.trim() && + obj?.questionId?.trim() && + obj?.operator?.trim() && + obj?.value != null && String(obj.value).trim() !== '' + ); +} + +function filterRequires(requires, selfId) { + if (!requires?.length) return []; + if (!selfId) return [...requires]; + return requires.filter(id => id !== selfId); +} + +/** Answer checks cannot reference the questionnaire being edited. */ +function sanitizeAnswerRule(rule, selfId) { + if (!selfId || rule.questionnaire !== selfId) { + return { ...rule, requires: filterRequires(rule.requires, selfId) }; + } + return { + ...rule, + requires: filterRequires(rule.requires, selfId), + questionnaire: '', + questionId: '', + value: '', + }; +} + +function parseRule(obj, selfId = '') { + if (!obj || typeof obj !== 'object') { + return { type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '' }; + } + if (obj.alwaysAvailable) { + return { type: 'always' }; + } + const rule = { + type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires', + requires: asRequiresList(obj), + questionnaire: obj.questionnaire || '', + questionId: obj.questionId || '', + operator: obj.operator === '!=' ? '!=' : '==', + value: obj.value != null ? String(obj.value) : '', + }; + return sanitizeAnswerRule(rule, selfId); +} + +export function parseConditionForm(conditionJson, questionnaireId) { + const raw = (conditionJson || '{}').trim() || '{}'; + let obj; + try { + obj = JSON.parse(raw); + } catch { + return { + mode: 'custom', + customJson: raw, + messageKey: defaultConditionMessageKey(questionnaireId), + germanLabel: '', + }; + } + + const messageKey = (obj.messageKey || '').trim() || defaultConditionMessageKey(questionnaireId); + + if (obj.alwaysAvailable === true) { + return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' }; + } + if (Array.isArray(obj.anyOf) && obj.anyOf.length) { + return { + mode: 'any_of', + rules: obj.anyOf.map(r => parseRule(r, questionnaireId)).filter(r => r.type !== 'always'), + messageKey, + germanLabel: '', + customJson: '', + }; + } + + const rule = parseRule(obj, questionnaireId); + return { + mode: rule.type === 'always' ? 'always' : rule.type, + requires: rule.requires, + questionnaire: rule.questionnaire, + questionId: rule.questionId, + operator: rule.operator, + value: rule.value, + messageKey, + germanLabel: '', + customJson: '', + }; +} + +function buildRule(rule) { + if (rule.type === 'always') { + return { alwaysAvailable: true }; + } + const out = {}; + if (rule.requires?.length) { + out.requiresCompleted = [...rule.requires]; + } + if (rule.type === 'requires_answer') { + out.questionnaire = (rule.questionnaire || '').trim(); + out.questionId = (rule.questionId || '').trim(); + out.operator = rule.operator === '!=' ? '!=' : '=='; + out.value = String(rule.value ?? '').trim(); + } + return out; +} + +function assertAnswerNotSelf(questionnaireId, selfId) { + if (selfId && questionnaireId === selfId) { + throw new Error('Answer check must use a different questionnaire (not this one)'); + } +} + +export function buildConditionJson(form, selfQuestionnaireId = '') { + if (form.mode === 'custom') { + const raw = (form.customJson || '{}').trim() || '{}'; + JSON.parse(raw); + return raw; + } + + let obj; + if (form.mode === 'always') { + obj = { alwaysAvailable: true }; + } else if (form.mode === 'any_of') { + const rules = (form.rules || []).map(buildRule).filter(r => Object.keys(r).length); + if (!rules.length) { + throw new Error('Add at least one rule for "Any of these rules"'); + } + obj = { anyOf: rules }; + } else { + obj = buildRule({ + type: form.mode, + requires: form.requires || [], + questionnaire: form.questionnaire, + questionId: form.questionId, + operator: form.operator, + value: form.value, + }); + if (form.mode === 'requires' && !obj.requiresCompleted?.length) { + throw new Error('Select at least one required questionnaire'); + } + if (form.mode === 'requires_answer') { + if (!obj.questionnaire) { + throw new Error('Select the questionnaire for the answer check'); + } + if (!obj.questionId) { + throw new Error('Select the question for the answer check'); + } + if (!obj.value) { + throw new Error('Enter or select the expected answer value'); + } + } + } + + const messageKey = (form.messageKey || '').trim(); + if (form.mode !== 'always' && messageKey) { + obj.messageKey = messageKey; + } + return JSON.stringify(obj); +} + +function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) { + const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId); + if (!list.length) { + return '

No other questionnaires to require.

'; + } + return `
+ ${list.map(q => { + const checked = selected.includes(q.questionnaireID) ? ' checked' : ''; + return ``; + }).join('')} +
`; +} + +function questionnaireSelectHTML(prefix, selectedId, allQuestionnaires, selfId, editable) { + const dis = editable ? '' : ' disabled'; + const list = (allQuestionnaires || []).filter( + q => q.questionnaireID && q.questionnaireID !== selfId + ); + const options = [''] + .concat(list.map(q => { + const id = q.questionnaireID; + const sel = selectedId === id ? ' selected' : ''; + const label = q.name ? `${q.name}` : id; + const suffix = q.name && q.name !== id ? ` (${id})` : ''; + return ``; + })); + return ``; +} + +function questionSelectHTML(prefix, questionnaireId, selectedQuestionId, questionsByQuestionnaire, editable) { + const dis = editable ? '' : ' disabled'; + const questions = questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId); + if (!questionnaireId) { + return ``; + } + const options = ['']; + for (const q of questions) { + const val = conditionQuestionId(q); + const sel = questionMatchesSelection(q, selectedQuestionId) ? ' selected' : ''; + options.push(``); + } + const known = questions.some(q => questionMatchesSelection(q, selectedQuestionId)); + if (selectedQuestionId && !known) { + options.push( + `` + ); + } + if (!questions.length) { + options.push(''); + } + return ``; +} + +function answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable) { + const dis = editable ? '' : ' disabled'; + const q = findQuestion(questionsByQuestionnaire, rule.questionnaire, rule.questionId); + const options = q?.answerOptions || []; + const hasChoices = q && CHOICE_LAYOUT_TYPES.has(q.type) && options.length > 0; + + if (hasChoices) { + const opts = ['']; + for (const opt of options) { + const val = (opt.optionKey || opt.defaultText || '').trim(); + if (!val) continue; + const label = opt.labelGerman || opt.defaultText || val; + const sel = rule.value === val ? ' selected' : ''; + opts.push(``); + } + if (rule.value && !options.some(o => (o.optionKey || o.defaultText || '').trim() === rule.value)) { + opts.push(``); + } + return ``; + } + + return ``; +} + +function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) { + const dis = editable ? '' : ' disabled'; + return ` +
+
+ + ${questionnaireSelectHTML(prefix, rule.questionnaire, allQuestionnaires, selfId, editable)} +

Must be a different questionnaire than this one.

+
+
+ + ${questionSelectHTML(prefix, rule.questionnaire, rule.questionId, questionsByQuestionnaire, editable)} +
+
+ + +
+
+ + ${answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable)} +
+
`; +} + +function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) { + const showAnswer = rule.type === 'requires_answer'; + return ` +
+ + ${requiresCheckboxes(`${prefix}Requires`, rule.requires || [], allQuestionnaires, selfId, editable)} + ${showAnswer ? ` + + ${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)} + ` : ''} +
`; +} + +export function renderConditionEditorHTML(form, { + questionnaireId, + allQuestionnaires, + questionsByQuestionnaire, + germanLabel, + editable, +}) { + const dis = editable ? '' : ' disabled'; + const modeOptions = CONDITION_MODES.map(m => + `` + ).join(''); + + const rules = form.rules?.length ? form.rules : [{ + type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '', + }]; + + return ` +
+

Availability

+
+ + +
+ +
+ ${singleRuleHTML('cond', { type: 'requires', requires: form.requires || [] }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ +
+ ${singleRuleHTML('cond', { + type: 'requires_answer', + requires: form.requires || [], + questionnaire: form.questionnaire || '', + questionId: form.questionId || '', + operator: form.operator || '==', + value: form.value || '', + }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ +
+

Unlock if any one rule matches.

+
+ ${rules.map((rule, i) => ` +
+
+ Rule ${i + 1} + ${editable && rules.length > 1 ? `` : ''} +
+
+ + +
+ ${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ `).join('')} +
+ ${editable ? '' : ''} +
+ +
+ +
+ +
+

Shown on the app when locked. Also listed under Translations → App strings.

+
+
+ + +
+
+ + +
+
+
+
`; +} + +function readRequires(containerId) { + const root = document.getElementById(containerId); + if (!root) return []; + return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value); +} + +function readAnswerPrefix(prefix) { + return { + questionnaire: document.getElementById(`${prefix}Questionnaire`)?.value?.trim() || '', + questionId: document.getElementById(`${prefix}QuestionId`)?.value?.trim() || '', + operator: document.getElementById(`${prefix}Operator`)?.value || '==', + value: document.getElementById(`${prefix}Value`)?.value?.trim() ?? '', + }; +} + +export function readConditionFormFromDOM() { + const mode = document.getElementById('conditionMode')?.value || 'always'; + const form = { mode, messageKey: '', germanLabel: '', customJson: '' }; + + if (mode === 'custom') { + form.customJson = document.getElementById('conditionCustomJson')?.value || '{}'; + try { + const obj = JSON.parse(form.customJson); + form.messageKey = (obj.messageKey || '').trim(); + } catch { /* validated on save */ } + form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || ''; + return form; + } + + if (mode === 'always') { + return form; + } + + form.messageKey = document.getElementById('conditionMessageKey')?.value?.trim() || ''; + form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || ''; + + if (mode === 'requires') { + form.requires = readRequires('condRequires'); + return form; + } + + if (mode === 'requires_answer') { + form.requires = readRequires('condRequires'); + Object.assign(form, readAnswerPrefix('cond')); + return form; + } + + if (mode === 'any_of') { + form.rules = []; + document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => { + const type = el.querySelector('.anyof-rule-type')?.value || 'requires'; + const requires = readRequires(`anyof${i}Requires`); + const rule = { type, requires, ...readAnswerPrefix(`anyof${i}`) }; + form.rules.push(rule); + }); + } + + return form; +} + +function wireConditionSelects(container, { questionnaireId, onChange }) { + container.querySelectorAll('.condition-qn-select').forEach(sel => { + sel.addEventListener('change', () => { + const prefix = sel.id.replace(/Questionnaire$/, ''); + const form = readConditionFormFromDOM(); + if (prefix === 'cond' && form.mode === 'requires_answer') { + const answer = readAnswerPrefix('cond'); + answer.questionnaire = sel.value; + if (answer.questionnaire !== form.questionnaire) { + answer.questionId = ''; + answer.value = ''; + } + form.questionnaire = answer.questionnaire; + form.questionId = answer.questionId; + form.value = answer.value; + } else if (form.rules) { + const idx = parseInt(prefix.replace(/^anyof/, ''), 10); + if (!Number.isNaN(idx) && form.rules[idx]) { + const prev = form.rules[idx].questionnaire; + form.rules[idx].questionnaire = sel.value; + if (sel.value !== prev) { + form.rules[idx].questionId = ''; + form.rules[idx].value = ''; + } + } + } + onChange(form); + }); + }); + + container.querySelectorAll('.condition-question-select').forEach(sel => { + sel.addEventListener('change', () => { + const prefix = sel.id.replace(/QuestionId$/, ''); + const form = readConditionFormFromDOM(); + const qId = sel.value; + if (prefix === 'cond' && form.mode === 'requires_answer') { + form.questionId = qId; + form.value = ''; + } else if (form.rules) { + const idx = parseInt(prefix.replace(/^anyof/, ''), 10); + if (!Number.isNaN(idx) && form.rules[idx]) { + form.rules[idx].questionId = qId; + form.rules[idx].value = ''; + } + } + onChange(form); + }); + }); +} + +/** + * Mount editor into container; returns { readForm, buildJson }. + */ +export function mountConditionEditor(container, { + questionnaireId, + allQuestionnaires, + questionsByQuestionnaire = {}, + conditionJson, + germanLabel, + editable, +}) { + let form = parseConditionForm(conditionJson, questionnaireId); + let label = germanLabel || ''; + + const render = () => { + container.innerHTML = renderConditionEditorHTML( + { ...form, germanLabel: label }, + { + questionnaireId, + allQuestionnaires, + questionsByQuestionnaire, + germanLabel: label, + editable, + } + ); + wire(); + }; + + const wire = () => { + const modeEl = document.getElementById('conditionMode'); + modeEl?.addEventListener('change', () => { + form = readConditionFormFromDOM(); + form.mode = modeEl.value; + if (form.mode === 'always') { + form.messageKey = ''; + } else if (!form.messageKey) { + form.messageKey = defaultConditionMessageKey(questionnaireId); + } + render(); + }); + + document.getElementById('addAnyOfRule')?.addEventListener('click', () => { + form = readConditionFormFromDOM(); + form.mode = 'any_of'; + form.rules = form.rules || []; + form.rules.push({ + type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '', + }); + label = document.getElementById('conditionGermanLabel')?.value || label; + render(); + }); + + container.querySelectorAll('.remove-anyof-rule').forEach(btn => { + btn.addEventListener('click', () => { + form = readConditionFormFromDOM(); + label = document.getElementById('conditionGermanLabel')?.value || label; + const idx = parseInt(btn.closest('.condition-anyof-rule')?.dataset.ruleIndex || '-1', 10); + form.rules.splice(idx, 1); + render(); + }); + }); + + container.querySelectorAll('.anyof-rule-type').forEach(sel => { + sel.addEventListener('change', () => { + form = readConditionFormFromDOM(); + label = document.getElementById('conditionGermanLabel')?.value || label; + const idx = parseInt(sel.dataset.ruleIndex || '0', 10); + if (form.rules?.[idx]) { + form.rules[idx].type = sel.value; + } + render(); + }); + }); + + wireConditionSelects(container, { + questionnaireId, + onChange: (updated) => { + form = updated; + label = document.getElementById('conditionGermanLabel')?.value || label; + render(); + }, + }); + }; + + render(); + + return { + readForm: () => { + form = readConditionFormFromDOM(); + label = document.getElementById('conditionGermanLabel')?.value?.trim() || label; + return { ...form, germanLabel: label }; + }, + buildJson: () => buildConditionJson(readConditionFormFromDOM(), questionnaireId), + }; +} diff --git a/website/js/confirm-modal.js b/website/js/confirm-modal.js new file mode 100644 index 0000000..cac0ad2 --- /dev/null +++ b/website/js/confirm-modal.js @@ -0,0 +1,148 @@ +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function formatMessageHTML(message) { + return String(message) + .split(/\n\n+/) + .map((block) => `

${esc(block).replace(/\n/g, '
')}

`) + .join(''); +} + +function renderChoicesHTML(choices) { + const name = 'confirm_modal_choice'; + return ` +
+
+ ${choices.map((choice, i) => ` + + `).join('')} +
+
`; +} + +/** @type {((value: boolean | string | null) => void) | null} */ +let resolveConfirm = null; + +function ensureConfirmModal() { + let modal = document.getElementById('confirmModal'); + if (modal) return modal; + + modal = document.createElement('div'); + modal.id = 'confirmModal'; + modal.className = 'modal-overlay'; + modal.hidden = true; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'confirmModalTitle'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + modal.addEventListener('click', (e) => { + if (e.target === modal) finishConfirm(null); + }); + modal.querySelector('[data-confirm-cancel]').addEventListener('click', () => finishConfirm(null)); + modal.querySelector('[data-confirm-submit]').addEventListener('click', () => { + const choicesEl = modal.querySelector('#confirmModalChoices'); + const hasChoices = choicesEl.childElementCount > 0; + if (hasChoices) { + const selected = modal.querySelector('input[name="confirm_modal_choice"]:checked'); + finishConfirm(selected ? selected.value : null); + } else { + finishConfirm(true); + } + }); + + document.addEventListener('keydown', (e) => { + const el = document.getElementById('confirmModal'); + if (!el || el.hidden) return; + if (e.key === 'Escape') finishConfirm(null); + }); + + return modal; +} + +function finishConfirm(value) { + const modal = document.getElementById('confirmModal'); + if (!modal || modal.hidden) return; + modal.hidden = true; + document.body.classList.remove('modal-open'); + const resolve = resolveConfirm; + resolveConfirm = null; + if (resolve) resolve(value); +} + +/** + * Show a confirmation dialog. + * + * Without `choices`: resolves to `true` (confirmed) or `false` (cancelled). + * With `choices`: resolves to the selected choice value (string) or `null` (cancelled). + * + * @param {Object} options + * @param {string} options.title + * @param {string} options.message + * @param {string} [options.confirmLabel='Confirm'] + * @param {string} [options.cancelLabel='Cancel'] + * @param {'default'|'danger'} [options.variant='default'] + * @param {{ value: string, label: string, description?: string }[]} [options.choices] + * @returns {Promise} + */ +export function confirmAction(options) { + return new Promise((resolve) => { + if (resolveConfirm) { + resolve(false); + return; + } + + const modal = ensureConfirmModal(); + const hasChoices = Array.isArray(options.choices) && options.choices.length > 0; + const variant = options.variant === 'danger' ? 'danger' : 'default'; + + modal.querySelector('#confirmModalTitle').textContent = options.title || 'Confirm'; + modal.querySelector('#confirmModalMessage').innerHTML = formatMessageHTML(options.message || ''); + + const choicesEl = modal.querySelector('#confirmModalChoices'); + choicesEl.innerHTML = hasChoices ? renderChoicesHTML(options.choices) : ''; + + const cancelBtn = modal.querySelector('[data-confirm-cancel]'); + const submitBtn = modal.querySelector('[data-confirm-submit]'); + cancelBtn.textContent = options.cancelLabel || 'Cancel'; + submitBtn.textContent = options.confirmLabel || (hasChoices ? 'Continue' : 'Confirm'); + submitBtn.className = `btn btn-sm ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`; + + modal.classList.toggle('confirm-modal--danger', variant === 'danger'); + + resolveConfirm = (value) => { + if (hasChoices) { + resolve(value); + } else { + resolve(value === true); + } + }; + + modal.hidden = false; + document.body.classList.add('modal-open'); + (hasChoices + ? modal.querySelector('input[name="confirm_modal_choice"]') + : submitBtn + )?.focus(); + }); +} diff --git a/website/js/pages/assignments.js b/website/js/pages/assignments.js new file mode 100644 index 0000000..978c350 --- /dev/null +++ b/website/js/pages/assignments.js @@ -0,0 +1,461 @@ +import { apiGet, apiPost } from '../api.js'; +import { pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; + +const CLIENT_PAGE_SIZE = 50; + +let coaches = []; +let clients = []; +let selectedCoachID = null; +let filterCoachSearch = ''; +let clientsByCoach = {}; +let clientPage = 0; +let selectedClientCodes = new Set(); + +export async function assignmentsPage() { + selectedCoachID = null; + filterCoachSearch = ''; + clientPage = 0; + selectedClientCodes = new Set(); + const app = document.getElementById('app'); + + app.innerHTML = ` + ${pageHeaderHTML('Assign Clients')} +
+ `; + + try { + const data = await apiGet('assignments.php'); + coaches = data.coaches || []; + clients = data.clients || []; + rebuildClientsByCoach(); + renderAssignments(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('assignContent').innerHTML = + `

${esc(e.message)}

`; + } +} + +function rebuildClientsByCoach() { + clientsByCoach = {}; + clients.forEach(c => { + if (c.coachID) { + (clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c); + } + }); +} + +function renderAssignments() { + const container = document.getElementById('assignContent'); + + if (!coaches.length) { + container.innerHTML = ` +
+

No counselors found

+

Create counselors first before assigning clients.

+
`; + return; + } + + const unassignedCount = clients.filter(c => !c.coachID).length; + + container.innerHTML = ` +
+
+ + + +
+
+
+

Clients

+
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + +
+ + Client CodeCurrent counselor
+
+
+ +
+
+ + +
+ `; + + document.getElementById('coachSearch').addEventListener('input', (e) => { + filterCoachSearch = e.target.value; + renderCoachList(); + }); + + document.getElementById('coachList').addEventListener('click', (e) => { + const item = e.target.closest('.coach-list-item'); + if (!item || item.dataset.action === 'clear-filter') return; + document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active')); + item.classList.add('active'); + selectedCoachID = item.dataset.id; + document.getElementById('filterByCoach').value = selectedCoachID; + clientPage = 0; + renderClientRows(); + }); + + document.getElementById('filterByCoach').addEventListener('change', (e) => { + const value = e.target.value; + clientPage = 0; + if (value && value !== '__unassigned__') { + selectedCoachID = value; + highlightCoachInList(value); + } else { + selectedCoachID = null; + document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active')); + } + renderClientRows(); + }); + + document.getElementById('clientSearch').addEventListener('input', () => { + clientPage = 0; + renderClientRows(); + }); + + document.getElementById('selectAllBtn').addEventListener('click', () => { + document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => { + cb.checked = true; + selectedClientCodes.add(cb.value); + }); + updateSelectionInfo(); + }); + document.getElementById('clearSelBtn').addEventListener('click', () => { + selectedClientCodes.clear(); + document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => { cb.checked = false; }); + syncSelectAllCb(); + updateSelectionInfo(); + }); + document.getElementById('selectAllCb').addEventListener('change', (e) => { + document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => { + cb.checked = e.target.checked; + if (e.target.checked) selectedClientCodes.add(cb.value); + else selectedClientCodes.delete(cb.value); + }); + updateSelectionInfo(); + }); + + document.getElementById('assignBtn').addEventListener('click', doAssign); + + renderCoachList(); + renderClientRows(); +} + +function getFilteredCoaches() { + const q = filterCoachSearch.trim().toLowerCase(); + if (!q) return coaches; + return coaches.filter(c => { + const hay = `${c.username} ${c.supervisorUsername || ''}`.toLowerCase(); + return hay.includes(q); + }); +} + +function renderCoachList() { + const list = document.getElementById('coachList'); + if (!list) return; + + const filtered = getFilteredCoaches(); + const q = filterCoachSearch.trim(); + + if (!filtered.length) { + list.innerHTML = `
  • ${q ? 'No counselors match' : 'No counselors'}
  • `; + return; + } + + list.innerHTML = filtered.map(c => coachListItemHTML(c)).join(''); + + if (selectedCoachID) { + highlightCoachInList(selectedCoachID); + } +} + +function coachListItemHTML(c) { + const count = (clientsByCoach[c.coachID] || []).length; + const active = selectedCoachID === c.coachID ? ' active' : ''; + return ` +
  • +
    ${esc(c.username)}
    + ${c.supervisorUsername + ? `
    ${esc(c.supervisorUsername)}
    ` + : ''} + ${count} client${count === 1 ? '' : 's'} +
  • `; +} + +function highlightCoachInList(coachID) { + document.querySelectorAll('.coach-list-item').forEach(li => { + li.classList.toggle('active', li.dataset.id === coachID); + }); + const active = document.querySelector(`.coach-list-item[data-id="${CSS.escape(coachID)}"]`); + active?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); +} + +function getVisibleClients() { + const coachFilter = document.getElementById('filterByCoach')?.value ?? ''; + const search = document.getElementById('clientSearch')?.value.trim().toLowerCase() ?? ''; + let visible = clients; + if (coachFilter === '__unassigned__') { + visible = clients.filter(c => !c.coachID); + } else if (coachFilter) { + visible = clients.filter(c => c.coachID === coachFilter); + } + if (search) { + visible = visible.filter(c => { + const hay = `${c.clientCode} ${c.coachUsername || ''}`.toLowerCase(); + return hay.includes(search); + }); + } + return visible; +} + +function updateClientSummary(filteredCount, pageSlice) { + const el = document.getElementById('clientListSummary'); + if (!el) return; + const search = document.getElementById('clientSearch')?.value.trim() ?? ''; + const coachFilter = document.getElementById('filterByCoach')?.value ?? ''; + const hasFilter = search || coachFilter; + const parts = []; + if (hasFilter && filteredCount !== clients.length) { + parts.push(`${filteredCount} of ${clients.length} match`); + } else { + parts.push(String(clients.length)); + } + if (pageSlice && filteredCount > CLIENT_PAGE_SIZE) { + const from = clientPage * CLIENT_PAGE_SIZE + 1; + const to = Math.min((clientPage + 1) * CLIENT_PAGE_SIZE, filteredCount); + parts.push(`rows ${from}–${to}`); + } + el.textContent = `(${parts.join(', ')})`; +} + +function renderPaginationBar(barId, totalRows, currentPage, pageSize, onPageChange) { + const pag = document.getElementById(barId); + if (!pag) return; + + if (!totalRows) { + pag.innerHTML = ''; + return; + } + + const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)); + if (totalRows <= pageSize) { + pag.innerHTML = `${totalRows} row${totalRows === 1 ? '' : 's'}`; + return; + } + + const from = currentPage * pageSize + 1; + const to = Math.min((currentPage + 1) * pageSize, totalRows); + const prevId = `${barId}Prev`; + const nextId = `${barId}Next`; + pag.innerHTML = ` + + Rows ${from}–${to} of ${totalRows} (page ${currentPage + 1}/${totalPages}) + + `; + document.getElementById(prevId)?.addEventListener('click', () => onPageChange(currentPage - 1)); + document.getElementById(nextId)?.addEventListener('click', () => onPageChange(currentPage + 1)); +} + +function syncSelectAllCb(codesOnPage = []) { + const allCb = document.getElementById('selectAllCb'); + if (!allCb) return; + if (!codesOnPage.length) { + allCb.checked = false; + allCb.indeterminate = false; + return; + } + const allSelected = codesOnPage.every(code => selectedClientCodes.has(code)); + const someSelected = codesOnPage.some(code => selectedClientCodes.has(code)); + allCb.checked = allSelected; + allCb.indeterminate = !allSelected && someSelected; +} + +function renderClientRows() { + const body = document.getElementById('clientTableBody'); + if (!body) return; + + const visible = getVisibleClients(); + const totalPages = Math.max(1, Math.ceil(visible.length / CLIENT_PAGE_SIZE)); + if (clientPage >= totalPages) clientPage = totalPages - 1; + + if (!visible.length) { + updateClientSummary(0, false); + body.innerHTML = ` + + + No clients match the current filters + + `; + renderPaginationBar('clientPagination', 0, clientPage, CLIENT_PAGE_SIZE, (p) => { + clientPage = p; + renderClientRows(); + }); + syncSelectAllCb(); + updateSelectionInfo(); + return; + } + + const slice = visible.slice(clientPage * CLIENT_PAGE_SIZE, (clientPage + 1) * CLIENT_PAGE_SIZE); + updateClientSummary(visible.length, visible.length > CLIENT_PAGE_SIZE); + + body.innerHTML = slice.map(c => { + const checked = selectedClientCodes.has(c.clientCode) ? ' checked' : ''; + return ` + + + + + ${esc(c.clientCode)} + ${c.coachUsername + ? esc(c.coachUsername) + : 'Unassigned'} + `; + }).join(''); + + body.querySelectorAll('.client-cb').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) selectedClientCodes.add(cb.value); + else selectedClientCodes.delete(cb.value); + syncSelectAllCb(slice.map(c => c.clientCode)); + updateSelectionInfo(); + }); + }); + + renderPaginationBar('clientPagination', visible.length, clientPage, CLIENT_PAGE_SIZE, (p) => { + clientPage = p; + renderClientRows(); + }); + syncSelectAllCb(slice.map(c => c.clientCode)); + updateSelectionInfo(); +} + +function getSelectedCodes() { + return [...selectedClientCodes]; +} + +function updateSelectionInfo() { + const selected = getSelectedCodes(); + const info = document.getElementById('selectionInfo'); + const card = document.getElementById('assignActionCard'); + const onPage = document.querySelectorAll('#clientTableBody .client-cb:checked').length; + + if (!selected.length) { + info.style.display = 'none'; + card.style.display = 'none'; + return; + } + info.style.display = ''; + const extra = selected.length > onPage + ? ` (${onPage} on this page)` + : ''; + info.innerHTML = `${selected.length} client${selected.length === 1 ? '' : 's'} selected${extra}`; + card.style.display = ''; + if (selectedCoachID) { + const sel = document.getElementById('targetCoachSelect'); + if (sel) sel.value = selectedCoachID; + } +} + +async function doAssign() { + const selected = getSelectedCodes(); + const coachID = document.getElementById('targetCoachSelect').value; + + if (!selected.length) { showToast('Select at least one client', 'error'); return; } + if (!coachID) { showToast('Select a target counselor', 'error'); return; } + + const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID; + if (!(await confirmAction({ + title: 'Assign clients', + message: `Assign ${selected.length} client(s) to ${coachName}?`, + confirmLabel: 'Assign', + }))) return; + + const btn = document.getElementById('assignBtn'); + btn.disabled = true; + btn.textContent = 'Assigning...'; + + try { + const data = await apiPost('assignments.php', { clientCodes: selected, coachID }); + if (data.success) { + selected.forEach(code => { + const c = clients.find(x => x.clientCode === code); + if (c) { + c.coachID = coachID; + c.coachUsername = coachName; + } + }); + selectedClientCodes.clear(); + rebuildClientsByCoach(); + showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success'); + renderAssignments(); + } + } catch (e) { + showToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Assign'; + } +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js new file mode 100644 index 0000000..aba5d0b --- /dev/null +++ b/website/js/pages/clients.js @@ -0,0 +1,938 @@ +import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; +import { pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; +import { + isClientArchived, + confirmAndPatchClientArchive, + clientTableActionsHTML, +} from '../client-archive.js'; + +const PAGE_SIZE = 50; +const ARCHIVE_FILTER_KEY = 'clientsArchiveFilter'; + +let clientsList = []; +let coachesList = []; +let scoringProfileCatalog = []; +let filterSearch = ''; +let archiveFilter = localStorage.getItem(ARCHIVE_FILTER_KEY) || 'active'; +let page = 0; +let expandedClientCode = null; +let expandedQnKey = null; +let expandedVersionKey = null; +let detailByClient = {}; + +export async function clientsPage() { + const app = document.getElementById('app'); + + app.innerHTML = ` + ${pageHeaderHTML('Clients', '')} + +
    + `; + + document.getElementById('showCreateFormBtn').addEventListener('click', () => { + const wrapper = document.getElementById('createClientWrapper'); + if (wrapper.style.display === 'none') { + showCreateForm(); + } else { + hideCreateForm(); + } + }); + + await loadClients(); +} + +async function loadClients() { + try { + const archiveQuery = archiveFilter !== 'active' + ? `?archiveFilter=${encodeURIComponent(archiveFilter)}` + : ''; + const [clientsData, assignData] = await Promise.all([ + apiGet(`clients.php${archiveQuery}`), + apiGet('assignments.php'), + ]); + clientsList = clientsData.clients || []; + scoringProfileCatalog = clientsData.scoringProfiles || []; + coachesList = assignData.coaches || []; + renderClients(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('clientsContent').innerHTML = + `

    ${esc(e.message)}

    `; + } +} + +function renderClients() { + const container = document.getElementById('clientsContent'); + + if (!clientsList.length) { + const emptyMsg = archiveFilter === 'archived' + ? '

    No archived clients

    Archived clients are hidden from assignments and follow-up.

    ' + : archiveFilter === 'all' + ? '

    No clients found

    Create the first client above.

    ' + : '

    No active clients

    Create a client above or switch the filter to view archived clients.

    '; + container.innerHTML = ` +
    +
    + ${archiveFilterSelectHTML()} + +
    +
    ${emptyMsg}
    +
    `; + bindArchiveFilterControl(); + document.getElementById('clientListSearch')?.addEventListener('input', (e) => { + filterSearch = e.target.value.trim(); + }); + return; + } + + const showProfileColumn = scoringProfileCatalog.length > 0; + const profileLegend = showProfileColumn ? scoringLegendHTML() : ''; + + container.innerHTML = ` +
    +
    + ${archiveFilterSelectHTML()} + + +
    + ${profileLegend} +

    + Expand a row to view questionnaire responses and upload version history. +

    +
    + + + + + + ${showProfileColumn ? '' : ''} + + + + +
    Client CodeCurrent counselorProfile scoresActions
    +
    +
    +
    `; + + bindArchiveFilterControl(); + document.getElementById('clientListSearch').addEventListener('input', (e) => { + filterSearch = e.target.value.trim(); + page = 0; + clearExpandIfHidden(); + renderClientTableBody(); + }); + ensureCoachBandGlobalClick(); + renderClientTableBody(); +} + +function archiveFilterSelectHTML() { + return ` + `; +} + +function bindArchiveFilterControl() { + const sel = document.getElementById('clientArchiveFilter'); + if (!sel) return; + sel.addEventListener('change', async (e) => { + archiveFilter = e.target.value; + localStorage.setItem(ARCHIVE_FILTER_KEY, archiveFilter); + page = 0; + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + detailByClient = {}; + document.getElementById('clientsContent').innerHTML = '
    '; + await loadClients(); + }); +} + +function getFilteredClientsList() { + let list = clientsList; + if (filterSearch) { + const q = filterSearch.toLowerCase(); + list = list.filter(c => { + const hay = `${c.clientCode || ''} ${c.coachUsername || ''}`.toLowerCase(); + return hay.includes(q); + }); + } + return list; +} + +function renderClientTableBody() { + const body = document.getElementById('clientsTableBody'); + const countEl = document.getElementById('clientListCount'); + const filtered = getFilteredClientsList(); + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; + if (page >= totalPages) page = totalPages - 1; + const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + if (countEl) { + countEl.textContent = `${filtered.length} client${filtered.length === 1 ? '' : 's'}`; + } + + if (!slice.length) { + body.innerHTML = `No clients match`; + } else { + body.innerHTML = slice.map(c => clientRowHTML(c)).join(''); + body.querySelectorAll('.client-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleClient(btn.dataset.code)); + }); + body.querySelectorAll('.client-qn-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleQn(btn.dataset.client, btn.dataset.qn)); + }); + body.querySelectorAll('.client-version-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleVersion( + btn.dataset.client, + btn.dataset.qn, + btn.dataset.submission + )); + }); + body.querySelectorAll('.delete-client-btn').forEach(btn => { + btn.addEventListener('click', () => deleteClient(btn.dataset.code)); + }); + body.querySelectorAll('.archive-client-btn').forEach(btn => { + btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true)); + }); + body.querySelectorAll('.restore-client-btn').forEach(btn => { + btn.addEventListener('click', () => setClientArchived(btn.dataset.code, false)); + }); + bindCoachBandPickerEvents(body); + } + + const pag = document.getElementById('clientsPagination'); + if (!pag) return; + if (filtered.length <= PAGE_SIZE) { + pag.innerHTML = ''; + return; + } + const from = page * PAGE_SIZE + 1; + const to = Math.min((page + 1) * PAGE_SIZE, filtered.length); + pag.innerHTML = ` + + Rows ${from}–${to} of ${filtered.length} + + `; + document.getElementById('clientsPagePrev')?.addEventListener('click', () => { + page--; + clearExpandIfHidden(); + renderClientTableBody(); + }); + document.getElementById('clientsPageNext')?.addEventListener('click', () => { + page++; + clearExpandIfHidden(); + renderClientTableBody(); + }); +} + +function qnKey(clientCode, questionnaireID) { + return `${clientCode}|${questionnaireID}`; +} + +function versionKey(clientCode, questionnaireID, submissionID) { + return `${clientCode}|${questionnaireID}|${submissionID}`; +} + +function clearExpandIfHidden() { + const filtered = getFilteredClientsList(); + const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + if (expandedClientCode && !slice.some(c => c.clientCode === expandedClientCode)) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + } +} + +function answerTableHTML(rows, { highlightLiveDiff = false } = {}) { + if (!rows?.length) { + return '

    No answers recorded

    '; + } + return ` + + + + ${rows.map(r => ` + + + + + `).join('')} + +
    QuestionAnswer
    ${esc(r.label)}${esc(r.value || '—')}
    `; +} + +function clientDetailPanelHTML(clientCode) { + const detail = detailByClient[clientCode]; + if (!detail) { + return '

    Loading…

    '; + } + const cl = detail.client || {}; + const questionnaires = detail.questionnaires || []; + if (!questionnaires.length) { + return ` +

    + Counselor: ${esc(cl.coachUsername || '—')} + ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''} +

    +

    No questionnaire data for this client yet.

    `; + } + + return ` +

    + Counselor: ${esc(cl.coachUsername || '—')} + ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''} +

    + ${renderClientScoringProfiles(detail.scoringProfiles || [], clientCode)} + ${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`; +} + +function scoringLegendHTML() { + return ` +
    +

    How profile scores work

    +

    + Each profile adds up questionnaire points (× weight) into a weighted total, + then maps that total to green + yellow + red using the thresholds on the Session measures page. +

    +
    +
    +
    Calculated
    +
    Automatic category from answers and profile rules (shown before Counselor review).
    +
    +
    +
    Counselor
    +
    Category confirmed or changed by the counselor in the app (“Review scores”).
    +
    +
    +
    Incomplete
    +
    Not all questionnaires in that profile are completed yet.
    +
    +
    +
    `; +} + +function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) { + const text = label || band || ''; + if (incomplete) { + return 'incomplete'; + } + if (pending) { + return 'awaiting review'; + } + if (!band) { + return ''; + } + return `${esc(text || band)}`; +} + +const COACH_BANDS = ['green', 'yellow', 'red']; + +function bandLabel(band) { + if (!band) return '—'; + return band.charAt(0).toUpperCase() + band.slice(1); +} + +function coachBandPickerHTML(p, clientCode) { + const calcBand = p.calculatedBand || p.band; + if (!calcBand || !clientCode || !p.profileID) { + return ''; + } + const current = p.coachBand || ''; + const coachPending = p.pendingReview !== false && !current; + const triggerBadge = coachPending + ? bandBadgeHTML(null, { pending: true }) + : bandBadgeHTML(current); + const showAgree = coachPending || (current && current !== calcBand); + const options = COACH_BANDS.map(b => ` + `).join(''); + const agreeBtn = showAgree ? ` + ` : ''; + return ` +
    + + +
    `; +} + +function profileScoreBlockHTML(p, clientCode = '') { + const calcBand = p.calculatedBand || p.band; + if (!calcBand) { + return ` +
    +
    ${esc(p.name)}
    +
    + Calculated + ${bandBadgeHTML(null, { incomplete: true })} +
    +

    Not all questionnaires in this profile are completed yet.

    +
    `; + } + const coachPending = p.pendingReview !== false && !p.coachBand; + const coachDiffers = p.coachBand && p.coachBand !== calcBand; + const coachControl = clientCode + ? coachBandPickerHTML(p, clientCode) + : (coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)); + return ` +
    +
    ${esc(p.name)}
    +
    + Calculated + ${bandBadgeHTML(calcBand)} + ${esc(String(p.weightedTotal))} +
    +
    + Counselor + ${coachControl} + ${coachDiffers ? 'override' : ''} +
    +
    `; +} + +function renderClientScoringProfiles(profiles, clientCode = '') { + if (!profiles.length) return ''; + return ` +
    + Scoring profiles +

    + Weighted totals and calculated vs Counselor categories for this client. +

    +
    ${profiles.map(p => { + const calc = p.calculatedBand || p.band; + if (!calc) return profileScoreBlockHTML(p, clientCode); + const coachPending = p.pendingReview !== false && !p.coachBand; + const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : ''; + const reviewedAt = p.coachReviewedAt ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : ''; + const meta = [computedAt, reviewedAt].filter(Boolean).join(' · '); + return ` +
    + ${profileScoreBlockHTML(p, clientCode)} + ${meta ? `

    ${meta}

    ` : ''} + ${coachPending ? '

    Counselor has not reviewed this profile in the app yet.

    ' : ''} +
    `; + }).join('')}
    +
    `; +} + +function clientQnBlockHTML(clientCode, q) { + const key = qnKey(clientCode, q.questionnaireID); + const qnExpanded = expandedQnKey === key; + const meta = [ + q.status ? `Status: ${q.status}` : '', + q.sumPoints != null ? `Points: ${q.sumPoints}` : '', + q.completedAt ? `Completed: ${q.completedAt}` : '', + q.submissionCount ? `${q.submissionCount} upload(s)` : '', + ].filter(Boolean).join(' · '); + + const versionsHTML = (q.submissions || []).map(s => { + const vKey = versionKey(clientCode, q.questionnaireID, s.submissionID); + const vExpanded = expandedVersionKey === vKey; + const vMeta = [ + s.submittedAt ? `Uploaded: ${s.submittedAt}` : '', + s.status ? `Status: ${s.status}` : '', + s.sumPoints != null ? `Points: ${s.sumPoints}` : '', + ].filter(Boolean).join(' · '); + return ` +
    +
    + + Version ${s.version} + ${s.structureRevision ? `struct rev ${s.structureRevision}` : ''} + ${s.changedCount > 0 + ? `${s.changedCount} diff vs live` + : 'Same as live'} + ${vMeta ? `${esc(vMeta)}` : ''} +
    + ${vExpanded ? ` +
    + ${s.changedCount > 0 + ? '

    Highlighted rows differ from current live data.

    ' + : ''} + ${answerTableHTML(s.answers, { highlightLiveDiff: true })} +
    + ` : ''} +
    `; + }).join(''); + + return ` +
    +
    + + ${esc(q.name)} + ${q.questionnaireVersion ? `v${esc(q.questionnaireVersion)}` : ''} + ${meta ? `${esc(meta)}` : ''} +
    + ${qnExpanded ? ` +
    +

    Current data (live)

    + ${answerTableHTML(q.currentAnswers)} + ${(q.submissions || []).length ? ` +

    Upload versions

    + ${versionsHTML} + ` : '

    No archived upload versions.

    '} +
    + ` : ''} +
    `; +} + +function profileDotsHTML(profiles, clientCode) { + if (!profiles?.length) { + return 'No scoring profiles'; + } + return `
    ${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}
    `; +} + +function patchClientScoringBand(clientCode, profileID, coachBand) { + const client = clientsList.find(c => c.clientCode === clientCode); + if (client?.scoringProfiles) { + const profile = client.scoringProfiles.find(p => p.profileID === profileID); + if (profile) { + profile.coachBand = coachBand; + profile.pendingReview = false; + } + } + const detail = detailByClient[clientCode]; + if (detail?.scoringProfiles) { + const profile = detail.scoringProfiles.find(p => p.profileID === profileID); + if (profile) { + profile.coachBand = coachBand; + profile.pendingReview = false; + } + } +} + +function resetCoachBandMenuPosition(menu) { + if (!menu) return; + menu.style.position = ''; + menu.style.left = ''; + menu.style.top = ''; + menu.style.minWidth = ''; + menu.style.zIndex = ''; + menu.style.visibility = ''; +} + +function positionCoachBandMenu(picker) { + const trigger = picker.querySelector('.coach-band-trigger'); + const menu = picker.querySelector('.coach-band-menu'); + if (!trigger || !menu) return; + menu.hidden = false; + menu.style.visibility = 'hidden'; + const rect = trigger.getBoundingClientRect(); + const menuWidth = Math.max(rect.width, 184); + const left = Math.min(rect.left, window.innerWidth - menuWidth - 8); + menu.style.position = 'fixed'; + menu.style.left = `${Math.max(8, left)}px`; + menu.style.minWidth = `${menuWidth}px`; + menu.style.zIndex = '10001'; + const menuHeight = menu.offsetHeight; + let top = rect.bottom + 6; + if (top + menuHeight > window.innerHeight - 8) { + top = rect.top - menuHeight - 6; + } + menu.style.top = `${Math.max(8, top)}px`; + menu.style.visibility = ''; +} + +function closeAllCoachBandMenus() { + document.querySelectorAll('.coach-band-picker.is-open').forEach(picker => { + picker.classList.remove('is-open'); + const menu = picker.querySelector('.coach-band-menu'); + const trigger = picker.querySelector('.coach-band-trigger'); + resetCoachBandMenuPosition(menu); + if (menu) menu.hidden = true; + if (trigger) trigger.setAttribute('aria-expanded', 'false'); + }); +} + +function toggleCoachBandMenu(picker) { + const isOpen = picker.classList.contains('is-open'); + closeAllCoachBandMenus(); + if (isOpen) return; + picker.classList.add('is-open'); + const trigger = picker.querySelector('.coach-band-trigger'); + positionCoachBandMenu(picker); + if (trigger) trigger.setAttribute('aria-expanded', 'true'); +} + +function openCoachBandConfirmModal({ clientCode, profileName, calculatedBand, coachBand }) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay coach-band-confirm-modal'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + document.body.classList.add('modal-open'); + + const close = (confirmed) => { + overlay.remove(); + document.body.classList.remove('modal-open'); + resolve(confirmed); + }; + overlay.querySelector('[data-coach-cancel]').addEventListener('click', () => close(false)); + overlay.querySelector('[data-coach-confirm]').addEventListener('click', () => close(true)); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(false); + }); + overlay.querySelector('.modal-dialog').addEventListener('click', (e) => e.stopPropagation()); + const onKey = (e) => { + if (e.key === 'Escape') close(false); + }; + document.addEventListener('keydown', onKey, { once: true }); + overlay.querySelector('[data-coach-confirm]').focus(); + }); +} + +async function promptCoachBandSave(picker, coachBand) { + const clientCode = picker.dataset.client; + const profileID = picker.dataset.profile; + const profileName = picker.dataset.profileName || profileID; + const calculatedBand = picker.dataset.calculated; + const current = picker.dataset.coach || ''; + + if (!coachBand || coachBand === current) return; + + const confirmed = await openCoachBandConfirmModal({ + clientCode, + profileName, + calculatedBand, + coachBand, + }); + if (!confirmed) return; + + const weightedRaw = picker.dataset.weighted; + const weightedTotal = weightedRaw !== '' ? Number(weightedRaw) : null; + picker.classList.add('is-saving'); + try { + await apiPut('clients.php', { + clientCode, + profileID, + coachBand, + calculatedBand, + weightedTotal, + }); + patchClientScoringBand(clientCode, profileID, coachBand); + showToast(`Counselor category saved for ${profileName}`, 'success'); + renderClientTableBody(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + picker.classList.remove('is-saving'); + } +} + +function bindCoachBandPickerEvents(root = document) { + root.querySelectorAll('.coach-band-trigger').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleCoachBandMenu(btn.closest('.coach-band-picker')); + }); + }); + root.querySelectorAll('.coach-band-option').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const picker = btn.closest('.coach-band-picker'); + closeAllCoachBandMenus(); + promptCoachBandSave(picker, btn.dataset.band); + }); + }); +} + +let coachBandGlobalClickBound = false; +function ensureCoachBandGlobalClick() { + if (coachBandGlobalClickBound) return; + coachBandGlobalClickBound = true; + document.addEventListener('click', () => closeAllCoachBandMenus()); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeAllCoachBandMenus(); + }); +} + +function clientHasResponseData(c) { + return Number(c.hasResponseData) === 1; +} + +function clientArchivedLabel(c) { + if (!isClientArchived(c) || !c.archivedAt) return ''; + const d = new Date(Number(c.archivedAt) * 1000); + const when = Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + return when ? ` Archived` : ''; +} + +function clientRowHTML(c) { + const coach = c.coachUsername + ? `${esc(c.coachUsername)}` + : `Unassigned`; + const archived = isClientArchived(c); + const canExpand = clientHasResponseData(c); + const expanded = canExpand && expandedClientCode === c.clientCode; + const expandControl = canExpand + ? ` ` + : ''; + const profileCol = scoringProfileCatalog.length > 0 + ? `${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}` + : ''; + const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; + const actions = clientTableActionsHTML({ + clientCode: esc(c.clientCode), + archived, + showDelete: true, + }); + + return ` + + + ${expandControl}${esc(c.clientCode)}${clientArchivedLabel(c)} + + ${coach} + ${profileCol} + ${actions} + + ${expanded ? ` + + +
    ${clientDetailPanelHTML(c.clientCode)}
    + + ` : ''}`; +} + +async function toggleClient(clientCode) { + const row = clientsList.find(c => c.clientCode === clientCode); + if (!row || !clientHasResponseData(row)) { + return; + } + if (expandedClientCode === clientCode) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + renderClientTableBody(); + return; + } + expandedClientCode = clientCode; + expandedQnKey = null; + expandedVersionKey = null; + renderClientTableBody(); + if (!detailByClient[clientCode]) { + try { + const data = await apiGet( + `clients.php?clientCode=${encodeURIComponent(clientCode)}&detail=1` + ); + detailByClient[clientCode] = data; + } catch (e) { + showToast(e.message, 'error'); + detailByClient[clientCode] = { client: {}, questionnaires: [] }; + } + renderClientTableBody(); + } +} + +function toggleQn(clientCode, questionnaireID) { + const key = qnKey(clientCode, questionnaireID); + expandedQnKey = expandedQnKey === key ? null : key; + expandedVersionKey = null; + renderClientTableBody(); +} + +function toggleVersion(clientCode, questionnaireID, submissionID) { + const key = versionKey(clientCode, questionnaireID, submissionID); + expandedVersionKey = expandedVersionKey === key ? null : key; + renderClientTableBody(); +} + +async function setClientArchived(clientCode, archived) { + const ok = await confirmAndPatchClientArchive(clientCode, archived); + if (!ok) return; + + if (archived && archiveFilter === 'active') { + clientsList = clientsList.filter(c => c.clientCode !== clientCode); + } else if (!archived && archiveFilter === 'archived') { + clientsList = clientsList.filter(c => c.clientCode !== clientCode); + } else { + const row = clientsList.find(c => c.clientCode === clientCode); + if (row) { + row.archived = archived ? 1 : 0; + row.archivedAt = archived ? Math.floor(Date.now() / 1000) : 0; + } + } + if (expandedClientCode === clientCode && archived) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + } + if (!clientsList.length) renderClients(); + else renderClientTableBody(); +} + +async function deleteClient(clientCode) { + if (!(await confirmAction({ + title: 'Delete client', + message: `Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + try { + await apiDelete('clients.php', { clientCode }); + clientsList = clientsList.filter(c => c.clientCode !== clientCode); + delete detailByClient[clientCode]; + if (expandedClientCode === clientCode) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + } + showToast(`Client "${clientCode}" deleted`, 'success'); + if (!clientsList.length) renderClients(); + else renderClientTableBody(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +function showCreateForm() { + if (!coachesList.length) { + showToast('No counselors available. Create a counselor first.', 'error'); + return; + } + + const wrapper = document.getElementById('createClientWrapper'); + wrapper.style.display = ''; + wrapper.innerHTML = ` +
    +

    Create New Client

    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    + `; + + const input = document.getElementById('cc_code'); + input.focus(); + wrapper.querySelectorAll('input').forEach(inp => { + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitCreateClient(); + if (e.key === 'Escape') hideCreateForm(); + }); + }); + + document.getElementById('cc_submit').addEventListener('click', submitCreateClient); + document.getElementById('cc_cancel').addEventListener('click', hideCreateForm); +} + +function hideCreateForm() { + const wrapper = document.getElementById('createClientWrapper'); + if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } +} + +async function submitCreateClient() { + const errEl = document.getElementById('cc_error'); + errEl.style.display = 'none'; + + const clientCode = document.getElementById('cc_code').value.trim(); + const coachID = document.getElementById('cc_coach').value; + + if (!clientCode) { showError(errEl, 'Client code is required'); return; } + if (!coachID) { showError(errEl, 'Please select a counselor'); return; } + + const btn = document.getElementById('cc_submit'); + btn.disabled = true; + btn.textContent = 'Creating...'; + + try { + const data = await apiPost('clients.php', { clientCode, coachID }); + const coach = coachesList.find(c => c.coachID === coachID); + clientsList.push({ + clientCode: data.clientCode, + coachID: coachID, + coachUsername: coach?.username ?? null, + }); + showToast(`Client "${data.clientCode}" created`, 'success'); + hideCreateForm(); + renderClients(); + } catch (e) { + showError(errEl, e.message); + btn.disabled = false; + btn.textContent = 'Create Client'; + } +} + +function showError(el, msg) { + el.textContent = msg; + el.style.display = ''; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/coaches.js b/website/js/pages/coaches.js new file mode 100644 index 0000000..0f8acfa --- /dev/null +++ b/website/js/pages/coaches.js @@ -0,0 +1,423 @@ +import { apiGet } from '../api.js'; +import { pageHeaderHTML, showToast } from '../app.js'; + +const FETCH_LIMIT = 100; +const CLIENT_GROUP_PAGE = 10; +const UPLOADS_VISIBLE = 5; + +let coachesList = []; +let expandedCoachId = null; +/** @type {Record} */ +let recentByCoach = {}; +/** @type {Record, showAllUploads: Set, filter: string }>} */ +let coachDetailUi = {}; +let filterSearch = ''; + +export async function coachesPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + ${pageHeaderHTML('Counselor activity')} +
    + `; + + try { + const data = await apiGet('coaches.php'); + coachesList = data.coaches || []; + renderCoaches(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('coachesContent').innerHTML = + `

    ${esc(e.message)}

    `; + } +} + +function coachSearchText(c) { + return [c.username, c.coachID, c.supervisorUsername].filter(Boolean).join(' '); +} + +function coachHasUploads(c) { + return (c.totalSubmissions ?? 0) > 0; +} + +function filteredCoaches() { + const q = filterSearch.trim().toLowerCase(); + if (!q) return coachesList; + return coachesList.filter(c => coachSearchText(c).toLowerCase().includes(q)); +} + +function getCoachUi(coachID) { + if (!coachDetailUi[coachID]) { + coachDetailUi[coachID] = { + clientLimit: CLIENT_GROUP_PAGE, + expandedClients: new Set(), + showAllUploads: new Set(), + filter: '', + }; + } + return coachDetailUi[coachID]; +} + +function groupSubmissionsByClient(submissions) { + const map = new Map(); + for (const s of submissions) { + const code = s.clientCode || ''; + if (!map.has(code)) { + map.set(code, []); + } + map.get(code).push(s); + } + const groups = []; + for (const [clientCode, uploads] of map) { + uploads.sort((a, b) => String(b.submittedAt).localeCompare(String(a.submittedAt))); + groups.push({ + clientCode, + uploads, + count: uploads.length, + lastAt: uploads[0]?.submittedAt || '', + }); + } + groups.sort((a, b) => String(b.lastAt).localeCompare(String(a.lastAt))); + return groups; +} + +function filterClientGroups(groups, q) { + if (!q) return groups; + const needle = q.toLowerCase(); + return groups.filter(g => { + if (g.clientCode.toLowerCase().includes(needle)) return true; + return g.uploads.some(u => + (u.questionnaireName || '').toLowerCase().includes(needle) + || (u.questionnaireID || '').toLowerCase().includes(needle) + ); + }); +} + +function uploadsTableHTML(uploads) { + if (!uploads.length) { + return '

    No uploads

    '; + } + return ` + + + + + + ${uploads.map(s => ` + + + + + + `).join('')} + +
    WhenQuestionnaireVer.
    ${esc(s.submittedAt)}${esc(s.questionnaireName)}${s.version}
    `; +} + +function coachRecentPanelHTML(coachID) { + const data = recentByCoach[coachID]; + if (!data) { + return '

    Loading…

    '; + } + const ui = getCoachUi(coachID); + const allGroups = groupSubmissionsByClient(data.submissions || []); + const groups = filterClientGroups(allGroups, ui.filter.trim()); + const visibleGroups = groups.slice(0, ui.clientLimit); + const hasMoreClients = groups.length > ui.clientLimit; + const hasMoreUploads = (data.submissions?.length ?? 0) < (data.total ?? 0); + + const summary = data.total + ? `${data.total} upload${data.total === 1 ? '' : 's'} · ${allGroups.length} client${allGroups.length === 1 ? '' : 's'}` + : 'No uploads'; + + let groupsHTML = ''; + if (!groups.length) { + groupsHTML = `

    ${ui.filter.trim() ? 'No matches' : 'No uploads'}

    `; + } else { + groupsHTML = visibleGroups.map(g => { + const expanded = ui.expandedClients.has(g.clientCode); + const showAll = ui.showAllUploads.has(g.clientCode); + const visibleUploads = showAll ? g.uploads : g.uploads.slice(0, UPLOADS_VISIBLE); + const hiddenCount = g.uploads.length - UPLOADS_VISIBLE; + return ` +
    + + ${expanded ? ` +
    + ${uploadsTableHTML(visibleUploads)} + ${!showAll && hiddenCount > 0 ? ` + + ` : ''} +
    + ` : ''} +
    `; + }).join(''); + } + + return ` +
    +
    + + ${esc(summary)}${ui.filter.trim() && groups.length !== allGroups.length + ? ` · ${groups.length} shown` + : ''} +
    +
    ${groupsHTML}
    +
    + ${hasMoreClients ? ` + + ` : ''} + ${hasMoreUploads ? ` + + ` : ''} +
    +
    `; +} + +function renderCoaches() { + const el = document.getElementById('coachesContent'); + + if (!coachesList.length) { + el.innerHTML = `

    No counselors

    `; + return; + } + + el.innerHTML = ` +
    +

    + Track uploads and client load per counselor. Expand a row to browse uploads by client. +

    +
    + + +
    +
    + + + + + + + + +
    CounselorSupervisorClientsTotal uploads7d30dLast upload
    +
    +
    + `; + + document.getElementById('coachListSearch').addEventListener('input', (e) => { + filterSearch = e.target.value; + const filtered = filteredCoaches(); + if ( + expandedCoachId && + !filtered.some(c => c.coachID === expandedCoachId && coachHasUploads(c)) + ) { + expandedCoachId = null; + } + renderCoachTableBody(); + }); + + renderCoachTableBody(); +} + +function coachRowHTML(c) { + const canExpand = coachHasUploads(c); + const expanded = canExpand && expandedCoachId === c.coachID; + const expandControl = canExpand + ? ` ` + : ''; + return ` + + ${expandControl}${esc(c.username)} + ${esc(c.supervisorUsername || '—')} + ${c.clientCount ?? 0} + ${c.totalSubmissions ?? 0} + ${c.submissionsLast7d ?? 0} + ${c.submissionsLast30d ?? 0} + ${esc(c.lastSubmissionAt || '—')} + + ${expanded ? ` + + ${coachRecentPanelHTML(c.coachID)} + ` : ''}`; +} + +function wireCoachDetailPanel(coachID) { + const panel = document.querySelector(`[data-coach-panel="${CSS.escape(coachID)}"]`); + if (!panel) return; + + panel.querySelector('.coach-detail-search')?.addEventListener('input', (e) => { + getCoachUi(coachID).filter = e.target.value; + getCoachUi(coachID).clientLimit = CLIENT_GROUP_PAGE; + renderCoachTableBody(); + }); + + panel.querySelectorAll('.coach-client-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const ui = getCoachUi(coachID); + const client = btn.dataset.client; + if (ui.expandedClients.has(client)) { + ui.expandedClients.delete(client); + } else { + ui.expandedClients.add(client); + } + renderCoachTableBody(); + }); + }); + + panel.querySelectorAll('.coach-show-all-uploads').forEach(btn => { + btn.addEventListener('click', () => { + getCoachUi(coachID).showAllUploads.add(btn.dataset.client); + renderCoachTableBody(); + }); + }); + + panel.querySelector('.coach-more-clients')?.addEventListener('click', () => { + getCoachUi(coachID).clientLimit += CLIENT_GROUP_PAGE; + renderCoachTableBody(); + }); + + panel.querySelector('.coach-more-uploads')?.addEventListener('click', () => { + loadMoreCoachUploads(coachID); + }); +} + +function renderCoachTableBody() { + const body = document.getElementById('coachTableBody'); + const countEl = document.getElementById('coachListCount'); + if (!body) return; + + const filtered = filteredCoaches(); + + if (!filtered.length) { + body.innerHTML = ` + + + No counselors match + + `; + if (countEl) { + countEl.textContent = filterSearch.trim() + ? `0 of ${coachesList.length} counselors` + : ''; + } + return; + } + + body.innerHTML = filtered.map(c => coachRowHTML(c)).join(''); + + if (countEl) { + const q = filterSearch.trim(); + countEl.textContent = q + ? `${filtered.length} of ${coachesList.length} counselors` + : `${coachesList.length} counselors${coachesList.length === 1 ? '' : ''}`; + } + + body.querySelectorAll('.coach-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleCoach(btn.dataset.coach)); + }); + + if (expandedCoachId) { + wireCoachDetailPanel(expandedCoachId); + } +} + +async function fetchCoachRecent(coachID, offset = 0, append = false) { + const data = await apiGet( + `coaches.php?coachID=${encodeURIComponent(coachID)}&recent=1` + + `&limit=${FETCH_LIMIT}&offset=${offset}` + ); + const submissions = data.submissions || []; + if (append && recentByCoach[coachID]) { + const existing = recentByCoach[coachID].submissions || []; + const seen = new Set(existing.map(s => s.submissionID)); + for (const s of submissions) { + if (!seen.has(s.submissionID)) { + existing.push(s); + } + } + recentByCoach[coachID] = { + submissions: existing, + total: data.total ?? existing.length, + }; + } else { + recentByCoach[coachID] = { + submissions, + total: data.total ?? submissions.length, + }; + } +} + +async function loadMoreCoachUploads(coachID) { + const data = recentByCoach[coachID]; + if (!data) return; + const btn = document.querySelector( + `[data-coach-panel="${CSS.escape(coachID)}"] .coach-more-uploads` + ); + if (btn) { + btn.disabled = true; + btn.textContent = 'Loading…'; + } + try { + await fetchCoachRecent(coachID, data.submissions.length, true); + renderCoachTableBody(); + } catch (e) { + showToast(e.message, 'error'); + if (btn) { + btn.disabled = false; + btn.textContent = 'Load more uploads'; + } + } +} + +async function toggleCoach(coachID) { + const coach = coachesList.find(c => c.coachID === coachID); + if (!coach || !coachHasUploads(coach)) { + return; + } + if (expandedCoachId === coachID) { + expandedCoachId = null; + renderCoachTableBody(); + return; + } + expandedCoachId = coachID; + if (!coachDetailUi[coachID]) { + coachDetailUi[coachID] = { + clientLimit: CLIENT_GROUP_PAGE, + expandedClients: new Set(), + showAllUploads: new Set(), + filter: '', + }; + } + renderCoachTableBody(); + if (!recentByCoach[coachID]) { + try { + await fetchCoachRecent(coachID, 0, false); + } catch (e) { + showToast(e.message, 'error'); + recentByCoach[coachID] = { submissions: [], total: 0 }; + } + renderCoachTableBody(); + } +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js new file mode 100644 index 0000000..3613bee --- /dev/null +++ b/website/js/pages/dev.js @@ -0,0 +1,643 @@ +import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } from '../api.js'; +import { getRole, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; +import { navigate } from '../router.js'; + +const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; + +const ACTIVITY_LABELS = { + app_sync: 'App sync', + app_change: 'App change', + web_change: 'Website change', + web_export: 'Website export', + api_error: 'API error', +}; + +export function devPage() { + if (getRole() !== 'admin') { + navigate('#/'); + return; + } + + const app = document.getElementById('app'); + const today = new Date().toISOString().slice(0, 10); + app.innerHTML = ` + ${pageHeaderHTML('Admin Settings')} +
    +

    Security & sessions

    +

    + Login rate limits apply per username and IP. Session length applies to new logins + (website and mobile). Password-change tokens use the temporary session length. +

    +
    +
    +
    +

    +
    +

    Revoke all sessions

    +

    + Signs out everyone — all admins, supervisors, and counselors on every device. + You will be logged out too and must sign in again. Use after a suspected compromise or policy change. +

    + + + +
    +
    +
    +

    Export all response data (ZIP)

    +

    + Download one CSV per questionnaire in a single ZIP (full server data). + Current data uses live answers; + All versions includes every upload (one row per submission). +

    +
    + + +
    +
    +
    +

    Dev import

    +

    + Local / test only. Imports prefixed dev users (dev_*), + clients (DEV-CL-*), and completed questionnaire runs. + Existing real users are not modified. Conflicts are skipped (usernames, client codes, completions). + Default password: socialvrlab. Questionnaires must already exist in the database. +

    +
    + + + Use dev-test-fixture.json from the repo root (regenerate with barometer-server/scripts/generate_dev_fixture.py after importing the latest questionnaires_bundle_*.json; uneven counselor load, multi-version uploads, natural timestamps). +
    + +
    + + +
    + +
    +
    +

    Reset database

    +

    + Destructive. Deletes all clients, completions, counselors, and supervisors + (including non-dev accounts). Admin logins and questionnaire definitions are kept. +

    + +
    +
    +

    API activity log

    +

    + App syncs, website edits (translations, users, questionnaires, …), + and exports/downloads. Routine page loads (lists, dashboards) are not logged. +

    +
    + + + + + +
    + +

    +
    + + + + + + + + + + + + + + + + + +
    Time (UTC)KindSubjectMethodRouteStatusDurationValuesPerformed by
    Loading…
    +
    +
    + `; + + let parsedFixture = null; + + document.getElementById('devFixtureFile').addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + const summary = document.getElementById('devFixtureSummary'); + const btn = document.getElementById('devImportBtn'); + parsedFixture = null; + btn.disabled = true; + summary.style.display = 'none'; + + if (!file) return; + + try { + const text = await file.text(); + parsedFixture = JSON.parse(text); + const st = parsedFixture.stats || {}; + summary.innerHTML = ` + Preview: + ${parsedFixture.admins?.length ?? st.admins ?? 0} admins, + ${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors, + ${parsedFixture.coaches?.length ?? st.coaches ?? 0} counselors, + ${parsedFixture.clients?.length ?? st.clients ?? 0} clients, + ${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions, + ${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded) + `; + summary.style.display = ''; + btn.disabled = false; + } catch (err) { + showToast('Invalid JSON: ' + err.message, 'error'); + } + }); + + document.getElementById('devRemoveBtn').addEventListener('click', async () => { + if (!(await confirmAction({ + title: 'Remove dev test data', + message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.', + confirmLabel: 'Remove', + variant: 'danger', + }))) { + return; + } + const btn = document.getElementById('devRemoveBtn'); + const pre = document.getElementById('devImportResult'); + btn.disabled = true; + btn.textContent = 'Removing…'; + pre.style.display = 'none'; + + try { + const res = await apiPost('dev', { + action: 'remove', + usernamePrefix: 'dev_', + clientCodePrefix: 'DEV-CL-', + }); + if (!res.success) { + showToast(res.error || res.message || 'Remove failed', 'error'); + return; + } + pre.textContent = JSON.stringify(res, null, 2); + pre.style.display = ''; + const d = res.deleted || {}; + showToast( + `Removed ${d.clients ?? 0} clients, ${d.users ?? 0} users, ` + + `${d.completed_questionnaires ?? 0} completions`, + 'success' + ); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Remove test data'; + } + }); + + document.getElementById('devWipeBtn').addEventListener('click', async () => { + if (!(await confirmAction({ + title: 'Wipe operational data', + message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.', + confirmLabel: 'Remove all', + variant: 'danger', + }))) { + return; + } + const btn = document.getElementById('devWipeBtn'); + const pre = document.getElementById('devImportResult'); + btn.disabled = true; + btn.textContent = 'Removing…'; + pre.style.display = 'none'; + + try { + const res = await apiPost('dev', { action: 'wipeExceptAdmins' }); + if (!res.success) { + showToast(res.error || res.message || 'Wipe failed', 'error'); + return; + } + pre.textContent = JSON.stringify(res, null, 2); + pre.style.display = ''; + const d = res.deleted || {}; + const kept = res.kept?.admins ?? '?'; + showToast( + `Wiped ${d.clients ?? 0} clients, ${d.users ?? 0} users, ` + + `${d.completed_questionnaires ?? 0} completions (${kept} admins kept)`, + 'success' + ); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Remove all data (keep admins)'; + } + }); + + wireAdminZipExport(); + wireActivityLog(); + wireSecuritySettings(); + + document.getElementById('devImportBtn').addEventListener('click', async () => { + if (!parsedFixture) return; + const btn = document.getElementById('devImportBtn'); + const pre = document.getElementById('devImportResult'); + btn.disabled = true; + btn.textContent = 'Importing…'; + pre.style.display = 'none'; + + try { + const res = await apiPost('dev', { fixture: parsedFixture }); + if (!res.success) { + showToast(res.error || 'Import failed', 'error'); + return; + } + pre.textContent = JSON.stringify(res, null, 2); + pre.style.display = ''; + const imp = res.imported || {}; + const sk = res.skipped || {}; + showToast( + `Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, ` + + `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} counselors ` + + `(skipped ${sk.completions ?? 0} existing)`, + 'success' + ); + if (res.errors?.length) { + showToast(`${res.errors.length} warning(s) — see log below`, 'error'); + } + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Import fixture'; + } + }); +} + +function renderActivityLogStatus(status) { + const el = document.getElementById('activityLogStatus'); + if (!el || !status) { + if (el) el.style.display = 'none'; + return; + } + if (!status.enabled) { + el.className = 'field-hint callout-warn'; + el.innerHTML = 'Activity logging is disabled (QDB_API_LOG=0 in .env).'; + el.style.display = ''; + return; + } + if (status.writable) { + el.className = 'field-hint'; + el.innerHTML = `Logging to ${escHtml(status.dir)}` + + (status.php_user ? ` (PHP user: ${escHtml(status.php_user)})` : ''); + el.style.display = ''; + return; + } + el.className = 'field-hint callout-danger'; + el.innerHTML = `Cannot write activity log. ${escHtml(status.error || 'Permission problem.')} +
    Path: ${escHtml(status.dir)}` + + (status.php_user ? `
    PHP runs as: ${escHtml(status.php_user)}` : '') + + (status.fix_hint ? `
    ${escHtml(status.fix_hint)}` : ''); + el.style.display = ''; +} + +function wireActivityLog() { + const dateEl = document.getElementById('activityLogDate'); + const kindEl = document.getElementById('activityLogKind'); + document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog); + dateEl?.addEventListener('change', loadActivityLog); + kindEl?.addEventListener('change', loadActivityLog); + loadActivityLog(); +} + +async function loadActivityLog() { + const tbody = document.getElementById('activityLogBody'); + const meta = document.getElementById('activityLogMeta'); + if (!tbody) return; + + const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10); + const activity = document.getElementById('activityLogKind')?.value || ''; + tbody.innerHTML = 'Loading…'; + + try { + const q = new URLSearchParams({ date, limit: '300' }); + if (activity) q.set('activity', activity); + const data = await apiGet(`activity-log?${q}`); + if (!data.success) throw new Error(data.error || 'Failed to load activity log'); + + renderActivityLogStatus(data.logStatus); + + const entries = data.entries || []; + const files = (data.files || []).join(', ') || 'none'; + const trunc = data.truncated ? ' (newest 300 shown)' : ''; + if (meta) { + meta.textContent = entries.length + ? `${entries.length} entries from ${files}${trunc}` + : `No entries for this date/filter (files: ${files})`; + } + + if (!entries.length) { + tbody.innerHTML = 'No activity recorded'; + return; + } + + tbody.innerHTML = entries.map((e) => { + const kind = e.activity || ''; + const label = ACTIVITY_LABELS[kind] || kind || '—'; + const badgeClass = kind ? `badge-activity-${kind}` : ''; + const time = formatActivityTime(e.ts); + const statusNum = e.status != null ? Number(e.status) : null; + const status = statusNum != null ? String(statusNum) : '—'; + const statusClass = statusNum != null && statusNum >= 400 ? 'error-text' : ''; + const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—'; + const subject = formatActivitySubject(e); + const performedBy = formatActivityPerformer(e); + const errPart = e.error + ? `
    ${escHtml(e.error)}
    ` + : ''; + return ` + ${escHtml(time)} + ${escHtml(label)} + ${subject} + ${escHtml(e.method || '')} + ${escHtml(e.route || '')} + ${escHtml(status)} + ${escHtml(dur)} + ${renderActivityChanges(e.changes)} + ${performedBy}${errPart} + `; + }).join(''); + } catch (err) { + if (meta) meta.textContent = ''; + tbody.innerHTML = `${escHtml(err.message)}`; + } +} + +function formatActivitySubject(entry) { + if (entry.target_username) { + return `${escHtml(entry.target_username)}`; + } + const changes = entry.changes || []; + const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username'); + if (userChange) { + const name = userChange.display || formatActivityChangeValue(userChange.value); + return `${escHtml(name)}`; + } + const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode')); + if (client) { + const code = client.display || formatActivityChangeValue(client.value); + return `client ${escHtml(code)}`; + } + const qClient = changes.find((c) => (c.field || '') === 'query.clientCode'); + if (qClient) { + return `client ${escHtml(formatActivityChangeValue(qClient.value))}`; + } + return ''; +} + +function formatActivityPerformer(entry) { + if (entry.actor_username) { + const role = entry.role ? ` (${escHtml(entry.role)})` : ''; + return `${escHtml(entry.actor_username)}${role}`; + } + if (entry.role) { + return escHtml(entry.role); + } + return '—'; +} + +function renderActivityChanges(changes) { + const list = Array.isArray(changes) ? changes : []; + if (!list.length) { + return ''; + } + const rows = list.map((c) => { + const field = c.field ?? ''; + const raw = formatActivityChangeValue(c.value); + const display = (c.display != null && String(c.display) !== '') + ? String(c.display) + : raw; + const showRaw = display !== raw + && !raw.startsWith('[REDACTED]') + && !raw.startsWith('[ENCRYPTED'); + return ` + ${escHtml(field)} + + ${escHtml(display)} + ${showRaw ? `
    ${escHtml(raw)}
    ` : ''} + + `; + }).join(''); + return `${rows}
    `; +} + +function formatActivityChangeValue(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value); +} + +function formatActivityTime(iso) { + if (!iso) return '—'; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().replace('T', ' ').slice(0, 19); + } catch { + return iso; + } +} + +function escHtml(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function filenameFromContentDisposition(res, fallback) { + const disp = res.headers.get('Content-Disposition'); + if (!disp) return fallback; + const m = /filename="([^"]+)"/i.exec(disp); + return m ? m[1] : fallback; +} + +async function downloadExportZip(url, defaultFilename) { + const res = await apiDownloadFetch(url); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Download failed' })); + throw new Error(err.error?.message || err.error || 'Download failed'); + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filenameFromContentDisposition(res, defaultFilename); + a.click(); + URL.revokeObjectURL(blobUrl); +} + +function wireAdminZipExport() { + document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => { + const btn = document.getElementById('exportAllCurrentZipBtn'); + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Preparing ZIP…'; + try { + await downloadExportZip(apiUrl('export?exportAll=1'), 'responses_export_current.zip'); + showToast('ZIP download started (current data)', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); + + document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => { + const btn = document.getElementById('exportAllVersionsZipBtn'); + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Preparing ZIP…'; + try { + await downloadExportZip(apiUrl('export?exportAll=1&allVersions=1'), 'responses_export_all_versions.zip'); + showToast('ZIP download started (all versions)', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); +} + +function wireSecuritySettings() { + const formEl = document.getElementById('securitySettingsForm'); + const metaEl = document.getElementById('securitySettingsMeta'); + const revokeInput = document.getElementById('revokeAllConfirm'); + const revokeBtn = document.getElementById('revokeAllSessionsBtn'); + if (!formEl) return; + + revokeInput?.addEventListener('input', () => { + if (revokeBtn) { + revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM; + } + }); + + revokeBtn?.addEventListener('click', async () => { + if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return; + if (!(await confirmAction({ + title: 'Revoke all sessions', + message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.', + confirmLabel: 'Revoke all', + variant: 'danger', + }))) { + return; + } + revokeBtn.disabled = true; + try { + const data = await apiPost('settings', { + action: 'revokeAllSessions', + confirmPhrase: REVOKE_ALL_CONFIRM, + }); + if (!data.success) throw new Error(data.error || 'Failed'); + showToast(`Revoked ${data.revokedSessions ?? 0} session(s). Signing you out…`, 'success'); + revokeInput.value = ''; + setTimeout(() => redirectToLogin(), 800); + } catch (err) { + showToast(err.message, 'error'); + revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM; + } + }); + + loadSecuritySettings(formEl, metaEl); +} + +async function loadSecuritySettings(formEl, metaEl) { + try { + const data = await apiGet('settings'); + if (!data.success) throw new Error(data.error || 'Failed to load settings'); + const s = data.settings || {}; + const active = data.activeSessions ?? 0; + if (metaEl) { + metaEl.textContent = `${active} active session(s) in the database.`; + } + formEl.innerHTML = ` +
    +
    + + + Before lockout (per username + IP) +
    +
    + + +
    +
    + + +
    +
    + + + Website & app after normal login +
    +
    + + + Forced password change flow +
    +
    + + `; + document.getElementById('saveSecuritySettingsBtn')?.addEventListener('click', () => { + saveSecuritySettings(formEl, metaEl); + }); + } catch (err) { + formEl.innerHTML = `

    ${escHtml(err.message)}

    `; + } +} + +async function saveSecuritySettings(formEl, metaEl) { + const btn = document.getElementById('saveSecuritySettingsBtn'); + const payload = { + login_max_attempts: parseInt(document.getElementById('setLoginMax')?.value || '10', 10), + login_window_seconds: parseInt(document.getElementById('setLoginWindow')?.value || '15', 10) * 60, + login_lockout_seconds: parseInt(document.getElementById('setLoginLockout')?.value || '15', 10) * 60, + session_ttl_seconds: parseInt(document.getElementById('setSessionDays')?.value || '30', 10) * 86400, + temp_session_ttl_seconds: parseInt(document.getElementById('setTempSession')?.value || '10', 10) * 60, + }; + if (btn) btn.disabled = true; + try { + const data = await apiPut('settings', payload); + if (!data.success) throw new Error(data.error || 'Save failed'); + showToast('Security settings saved', 'success'); + await loadSecuritySettings(formEl, metaEl); + } catch (err) { + showToast(err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + +function escAttr(v) { + return String(v ?? '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/ +
    + ${homeNavButton()} +
    +

    ${isNew ? 'New Questionnaire' : 'Loading...'}

    +
    +
    +
    + Session measures + ${!isNew ? `View Results` : ''} +
    + +
    + `; + + try { + const qnList = await apiGet('questionnaires.php'); + allQuestionnaires = qnList.questionnaires || []; + } catch { + allQuestionnaires = []; + } + + if (isNew) { + questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft', + orderIndex: 0, showPoints: 0, conditionJson: '{}' }; + questions = []; + renderEditor(); + } else { + try { + const quData = await apiGet( + `questions.php?questionnaireID=${encodeURIComponent(params.id)}&includeRetired=1` + ); + questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id); + if (!questionnaire) { + showToast('Questionnaire not found', 'error'); + navigate('#/questionnaires'); + return; + } + questionnaire.structureRevision = quData.structureRevision ?? questionnaire.structureRevision ?? 1; + const allQs = quData.questions || []; + questions = allQs.filter(q => !q.retired); + retiredQuestions = allQs.filter(q => q.retired); + document.getElementById('editorTitle').textContent = questionnaire.name; + renderEditor(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('editorContent').innerHTML = `

    ${esc(e.message)}

    `; + } + } +} + +// ── Layout helpers ─────────────────────────────────────────────────────── + +function layoutSelectHTML(selected = 'radio_question', id = '') { + return ``; +} + +function layoutLabel(value) { + const t = LAYOUT_TYPES.find(t => t.value === value); + return t ? t.label : value || 'unknown'; +} + +const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; + +function parseConfig(q) { + try { return JSON.parse(q.configJson || '{}'); } catch { return {}; } +} + +function questionKeyOf(q) { + const cfg = typeof q === 'object' && q.configJson !== undefined ? parseConfig(q) : (q.config || q); + return (q.questionKey || cfg.questionKey || '').trim(); +} + +function questionLocalIds() { + return questions.map(q => { + const parts = q.questionID.split('__'); + const localId = parts[parts.length - 1]; + return { + questionID: q.questionID, + localId, + defaultText: q.defaultText, + questionKey: questionKeyOf(q), + }; + }); +} + +function branchTargetLabel(q) { + const key = questionKeyOf(q) || q.localId; + return `${q.localId} · ${key}`; +} + +function branchTargetOptionsHTML(selected = '') { + return questionLocalIds().map(q => + `` + ).join(''); +} + +function validateStableKey(key, label = 'Key') { + if (!STABLE_KEY_RE.test(key)) { + showToast(`${label} must match [a-zA-Z][a-zA-Z0-9_]*`, 'error'); + return false; + } + return true; +} + +function optionKeyOf(o) { + if (o.optionKey) return o.optionKey; + const dt = (o.defaultText || '').trim(); + return STABLE_KEY_RE.test(dt) ? dt : ''; +} + +function optionGermanLabel(o) { + return (o.labelGerman || '').trim() || (STABLE_KEY_RE.test(o.defaultText || '') ? '' : o.defaultText) || ''; +} + +function notesSectionHTML(config, prefix, questionKey) { + const nbKey = questionKey ? `${questionKey}_note_before` : '—'; + const naKey = questionKey ? `${questionKey}_note_after` : '—'; + return ` +
    +

    Notes (German)

    +
    +
    + + + Key: ${esc(nbKey)} +
    +
    + + + Key: ${esc(naKey)} +
    +
    +
    `; +} + +function readNotesFromForm(prefix) { + const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || ''; + return { noteBefore: val('noteBefore'), noteAfter: val('noteAfter') }; +} + +function glassSymptomsRowsFromQuestion(q) { + if (Array.isArray(q?.glassSymptoms) && q.glassSymptoms.length) { + return q.glassSymptoms.map(r => ({ + key: (r.key || '').trim(), + labelDe: (r.labelDe || '').trim(), + scoreLevels: r.scoreLevels || defaultGlassScoreLevels(), + })); + } + const config = parseConfig(q); + return (config.symptoms || []).map(key => ({ + key: String(key).trim(), + labelDe: '', + scoreLevels: defaultGlassScoreLevels(), + })); +} + +function defaultGlassScoreLevels() { + return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 }; +} + +function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) { + const min = Number(config.range?.min ?? 0); + const max = Number(config.range?.max ?? 100); + const step = Math.max(1, Number(config.step ?? 1)); + const lo = Math.min(min, max); + const hi = Math.max(min, max); + const ruleMap = {}; + for (const r of scoreRules || []) { + ruleMap[r.levelKey] = r.points; + } + const values = []; + for (let v = lo; v <= hi; v += step) values.push(v); + if (!values.length) values.push(lo); + const rows = values.map(v => ` +
    + ${v} + +
    `).join(''); + return ` +
    + +

    Set points for each selectable value (used when the questionnaire is scored).

    +
    ${rows}
    +
    `; +} + +function readValueScoreRulesFromForm(prefix) { + const container = document.getElementById(`${prefix}_value_scores`); + if (!container) return []; + const rules = []; + container.querySelectorAll('.value-score-pts').forEach(inp => { + rules.push({ + scopeKey: '', + levelKey: String(inp.dataset.value), + points: parseInt(inp.value || '0', 10) || 0, + }); + }); + return rules; +} + +function wireValueScoreRules(prefix) { + const minInp = document.getElementById(`${prefix}_rangeMin`); + const maxInp = document.getElementById(`${prefix}_rangeMax`); + const stepInp = document.getElementById(`${prefix}_step`); + const rerender = () => { + const section = document.getElementById(`${prefix}_value_scores`); + if (!section) return; + const config = { + range: { + min: parseInt(minInp?.value || '0', 10), + max: parseInt(maxInp?.value || '100', 10), + }, + step: parseInt(stepInp?.value || '1', 10) || 1, + }; + const existing = readValueScoreRulesFromForm(prefix); + section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing); + }; + [minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender)); +} + +function suggestSymptomKey(label) { + let slug = (label || '').trim().toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + if (!slug) return ''; + if (!/^[a-z]/.test(slug)) slug = `sym_${slug}`; + if (!STABLE_KEY_RE.test(slug)) slug = `sym_${slug.replace(/^[^a-z]+/, '')}`; + return STABLE_KEY_RE.test(slug) ? slug : ''; +} + +function glassSymptomsSectionHTML(rows, prefix) { + const list = rows.length ? rows : []; + return ` +
    +
    + + +
    +

    Each row is one table line in the app. German text is saved with the question; translate other languages on the Translations tab.

    +
      + ${list.length ? list.map((row, i) => glassSymptomRowHTML(prefix, row, i)).join('') + : '
    • No symptoms yet.
    • '} +
    +
    `; +} + +function glassSymptomRowHTML(prefix, row, idx) { + const levels = row.scoreLevels || defaultGlassScoreLevels(); + const ptsRow = GLASS_SCORE_LEVELS.map(l => ` +
    + + +
    `).join(''); + return ` +
  • +
    +
    + + +
    +
    + + +
    + +
    +
    ${ptsRow}
    +
  • `; +} + +function readGlassSymptomsFromForm(prefix) { + const container = document.getElementById(`${prefix}_glass_symptoms`); + if (!container) return []; + const rows = []; + container.querySelectorAll('.glass-symptom-row').forEach(rowEl => { + const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || ''; + const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || ''; + const scoreLevels = {}; + rowEl.querySelectorAll('.glass-score-pts').forEach(inp => { + scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0; + }); + if (key || labelDe) rows.push({ key, labelDe, scoreLevels }); + }); + return rows; +} + +function validateGlassSymptoms(rows) { + const seen = new Set(); + for (const row of rows) { + if (!row.key) { + showToast('Each symptom needs a key', 'error'); + return false; + } + if (!validateStableKey(row.key, 'Symptom key')) return false; + if (!row.labelDe) { + showToast(`German label is required for symptom "${row.key}"`, 'error'); + return false; + } + if (seen.has(row.key)) { + showToast(`Duplicate symptom key "${row.key}"`, 'error'); + return false; + } + seen.add(row.key); + } + return true; +} + +function wireGlassSymptomsSection(prefix) { + const container = document.getElementById(`${prefix}_glass_symptoms`); + if (!container) return; + + const renderList = (rows) => { + const list = container.querySelector('.glass-symptoms-list'); + if (!list) return; + if (!rows.length) { + list.innerHTML = '
  • No symptoms yet.
  • '; + return; + } + list.innerHTML = rows.map((row, i) => glassSymptomRowHTML(prefix, row, i)).join(''); + bindRowEvents(); + }; + + const collectRows = () => readGlassSymptomsFromForm(prefix); + + const bindRowEvents = () => { + container.querySelectorAll('.glass-symptom-remove').forEach(btn => { + btn.addEventListener('click', () => { + const rowEl = btn.closest('.glass-symptom-row'); + rowEl?.remove(); + const list = container.querySelector('.glass-symptoms-list'); + if (list && !list.querySelector('.glass-symptom-row')) { + list.innerHTML = '
  • No symptoms yet.
  • '; + } + }); + }); + container.querySelectorAll('.glass-symptom-label').forEach(inp => { + inp.addEventListener('blur', () => { + const rowEl = inp.closest('.glass-symptom-row'); + const keyInp = rowEl?.querySelector('.glass-symptom-key'); + if (!keyInp || keyInp.value.trim()) return; + const suggested = suggestSymptomKey(inp.value); + if (suggested) keyInp.value = suggested; + }); + }); + }; + + container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => { + const rows = collectRows(); + rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() }); + renderList(rows); + const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label'); + lastKey?.focus(); + }); + + bindRowEvents(); +} + +function stringSpinnerOtherEnabled(config) { + return Boolean(config.otherNextQuestionId || config.otherOptionKey); +} + +/** Question types that use config.nextQuestionId (not per-option branching). */ +const QUESTION_LEVEL_NEXT_LAYOUTS = new Set([ + 'value_spinner', + 'slider_question', + 'free_text', + 'date_spinner', + 'multi_check_box_question', + 'glass_scale_question', + 'client_coach_code_question', + 'last_page', +]); + +function questionNextSectionHTML(config, prefix, { label, hint } = {}) { + const next = config.nextQuestionId || ''; + return ` +
    + + + ${hint || 'Where to go after this question. Leave empty to continue in list order.'} +
    `; +} + +function stringSpinnerNextSectionHTML(config, prefix) { + return questionNextSectionHTML(config, prefix, { + label: 'Next question (list selection)', + hint: 'Where to go after a normal spinner choice. Required when “Other” targets a question that sits next in order (so list picks can skip it).', + }); +} + +function readQuestionNextFromForm(prefix, config) { + const next = document.getElementById(`${prefix}_nextQuestionId`)?.value?.trim() || ''; + if (next) config.nextQuestionId = next; +} + +function stringSpinnerOtherSectionHTML(config, prefix) { + const enabled = stringSpinnerOtherEnabled(config); + const otherKey = config.otherOptionKey || 'other_option'; + const otherNext = config.otherNextQuestionId || ''; + return ` +
    + +
    +
    +
    + + + Appends this label to the spinner. Translate under Questionnaire UI strings (or App strings if listed in catalog). +
    +
    + + + Use layout Free text for the follow-up (e.g. country name). +
    +
    +
    +
    `; +} + +function wireStringSpinnerOtherToggle(prefix) { + const cb = document.getElementById(`${prefix}_otherEnabled`); + const panel = document.getElementById(`${prefix}_otherFields`); + if (!cb || !panel) return; + const sync = () => { panel.style.display = cb.checked ? '' : 'none'; }; + cb.addEventListener('change', sync); + sync(); +} + +function validateStringSpinnerConfig(prefix) { + const defaultNext = document.getElementById(`${prefix}_nextQuestionId`)?.value.trim() || ''; + const enabled = document.getElementById(`${prefix}_otherEnabled`)?.checked; + if (enabled) { + const key = document.getElementById(`${prefix}_otherOptionKey`)?.value.trim() || ''; + const otherNext = document.getElementById(`${prefix}_otherNextQuestionId`)?.value.trim() || ''; + if (!key) { + showToast('Other label translation key is required', 'error'); + return false; + } + if (!validateStableKey(key, 'Other label key')) return false; + if (!otherNext) { + showToast('Select the free-text question for “Other”', 'error'); + return false; + } + const target = questions.find(q => (q.localId || q.questionID.split('__').pop()) === otherNext); + if (target && target.type !== 'free_text') { + showToast('“Other” should branch to a Free text question', 'error'); + return false; + } + if (!defaultNext) { + showToast('Set “Next question (list selection)” so normal choices do not fall through to the free-text step', 'error'); + return false; + } + if (defaultNext === otherNext) { + showToast('“Next question” and “Other” branch cannot target the same question', 'error'); + return false; + } + } + return true; +} + +// ── Config form HTML for each layout type ──────────────────────────────── + +function configFormHTML(layout, config, prefix, opts = {}) { + let html = ''; + switch (layout) { + case 'radio_question': + html = ` +
    + + +
    +

    Branching: set Next question on each answer option below.

    `; + break; + case 'multi_check_box_question': + html = ` +
    + + +
    + ${stringSpinnerOtherSectionHTML(config, prefix)}`; + break; + case 'glass_scale_question': { + const scaleType = config.scaleType || 'glass'; + const rows = opts.glassSymptoms || (config.symptoms || []).map(k => ({ key: k, labelDe: '' })); + html = ` +
    + + +
    + ${glassSymptomsSectionHTML(rows, prefix)}`; + break; + } + case 'value_spinner': + case 'slider_question': { + const scoreRules = opts.scoreRules || []; + html = ` +
    +
    + + +
    +
    + + +
    + ${layout === 'slider_question' ? ` +
    + + +
    +
    + + +
    ` : ''} +
    + ${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`; + break; + } + case 'date_spinner': { + const notBefore = config.constraints?.notBefore || ''; + const notAfter = config.constraints?.notAfter || ''; + const precision = config.precision || 'full'; + html = ` +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    `; + break; + } + case 'string_spinner': + html = ` +
    + + + Do not include the “Other” row here; enable it below. +
    + ${stringSpinnerNextSectionHTML(config, prefix)} + ${stringSpinnerOtherSectionHTML(config, prefix)}`; + break; + case 'free_text': + html = ` +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    `; + break; + case 'client_coach_code_question': + html = ` +
    +
    + + +
    +
    + + +
    +
    `; + break; + case 'last_page': + html = ` +
    + + +
    `; + break; + } + if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout)) { + html += questionNextSectionHTML(config, prefix); + } + return html ? `
    ${html}
    ` : ''; +} + +function readConfigFromForm(layout, prefix) { + const config = {}; + const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || ''; + + switch (layout) { + case 'radio_question': + if (val('textKey')) config.textKey = val('textKey'); + break; + case 'multi_check_box_question': { + const ms = parseInt(val('minSelection') || '0', 10); + if (ms > 0) config.minSelection = ms; + const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked; + if (otherEnabled) { + const ok = val('otherOptionKey') || 'other_option'; + const on = val('otherNextQuestionId'); + if (ok) config.otherOptionKey = ok; + if (on) config.otherNextQuestionId = on; + } + break; + } + case 'glass_scale_question': { + const st = val('scaleType') || 'glass'; + if (st && st !== 'glass') config.scaleType = st; + break; + } + case 'value_spinner': + case 'slider_question': + config.range = { + min: parseInt(val('rangeMin') || '0', 10), + max: parseInt(val('rangeMax') || '100', 10), + }; + if (layout === 'slider_question') { + const step = parseInt(val('step') || '1', 10); + if (step > 0) config.step = step; + if (val('unitLabel')) config.unitLabel = val('unitLabel'); + } + break; + case 'date_spinner': { + const prec = val('precision') || 'full'; + if (prec && prec !== 'full') config.precision = prec; + const nb = val('notBefore'); + const na = val('notAfter'); + if (nb || na) { + config.constraints = {}; + if (nb) config.constraints.notBefore = nb; + if (na) config.constraints.notAfter = na; + } + break; + } + case 'string_spinner': { + const raw = val('stringOptions'); + const opts = raw.split('\n').map(s => s.trim()).filter(Boolean); + if (opts.length) config.options = opts; + const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked; + if (otherEnabled) { + const ok = val('otherOptionKey') || 'other_option'; + const on = val('otherNextQuestionId'); + if (ok) config.otherOptionKey = ok; + if (on) config.otherNextQuestionId = on; + } + break; + } + case 'free_text': { + if (val('textKey')) config.textKey = val('textKey'); + if (val('hint')) config.hint = val('hint'); + const ml = parseInt(val('maxLength') || '500', 10); + if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000); + break; + } + case 'client_coach_code_question': + if (val('hint1')) config.hint1 = val('hint1'); + if (val('hint2')) config.hint2 = val('hint2'); + break; + case 'last_page': + if (val('textKey')) config.textKey = val('textKey'); + break; + } + if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout) || layout === 'string_spinner') { + readQuestionNextFromForm(prefix, config); + } + return JSON.stringify(config); +} + +// ── Main editor render ─────────────────────────────────────────────────── + +function renderEditor() { + const container = document.getElementById('editorContent'); + const editable = canEdit(); + const condJson = questionnaire.conditionJson || '{}'; + + container.innerHTML = ` +
    +

    Questionnaire Details

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + ${editable ? `` : ''} +
    + + ${!isNew ? ` +
    + + + +
    +
    +
    +
    +

    Questions

    + ${editable ? `` : ''} +
    + +
      + +
      +
      + + + ` : '

      Save the questionnaire first, then add questions.

      '} + `; + + if (editable) { + document.getElementById('saveMetaBtn').addEventListener('click', saveMeta); + } + initConditionEditor(condJson); + + if (!isNew) { + initTabs(); + renderQuestions(); + renderRetiredQuestions(); + if (editable) { + document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm); + initDragReorder(); + } + } +} + +function applyStructureRevision(rev) { + if (rev == null) return; + questionnaire.structureRevision = rev; + const input = document.querySelector('.meta-form input[disabled][title*="Auto-incremented"]'); + if (input) input.value = String(rev); +} + +function notifyStructureBump(data) { + const rev = data?.structureRevision; + if (rev != null) { + applyStructureRevision(rev); + showToast(`Structure revision bumped to ${rev}.`, 'info'); + } +} + +function initTabs() { + const tabBar = document.getElementById('editorTabs'); + if (!tabBar) return; + tabBar.querySelectorAll('button').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + if (tab === activeTab) return; + activeTab = tab; + tabBar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); + document.getElementById('tabContent-questions').style.display = tab === 'questions' ? '' : 'none'; + document.getElementById('tabContent-translations').style.display = tab === 'translations' ? '' : 'none'; + document.getElementById('tabContent-preview').style.display = tab === 'preview' ? '' : 'none'; + if (tab === 'translations') loadTranslationsTab(); + if (tab === 'preview') loadPreviewTab(); + }); + }); +} + +function loadPreviewTab() { + const mount = document.getElementById('previewContent'); + if (!mount) return; + mountQuestionnairePreview(mount, { + questionnaireName: questionnaire?.name || '', + questions, + }); +} + +async function fetchAppStringGerman(messageKey) { + if (!messageKey) return ''; + try { + const data = await apiGet( + `translations.php?type=app_string&id=${encodeURIComponent(messageKey)}` + ); + const row = (data.translations || []).find( + tr => tr.languageCode === SOURCE_LANG + ); + return row?.text?.trim() || ''; + } catch { + return ''; + } +} + +async function loadQuestionsByQuestionnaire() { + const ids = allQuestionnaires.map(q => q.questionnaireID).filter(Boolean); + if (!ids.length) { + questionsByQuestionnaire = {}; + return; + } + const pairs = await Promise.all(ids.map(async id => { + try { + const data = await apiGet( + `questions.php?questionnaireID=${encodeURIComponent(id)}` + ); + return [id, data.questions || []]; + } catch { + return [id, []]; + } + })); + questionsByQuestionnaire = Object.fromEntries(pairs); +} + +async function initConditionEditor(condJson) { + const mount = document.getElementById('conditionEditorMount'); + if (!mount) return; + mount.innerHTML = '
      '; + await loadQuestionsByQuestionnaire(); + const qid = questionnaire?.questionnaireID || 'new'; + const parsed = parseConditionForm(condJson, qid); + const germanLabel = parsed.messageKey + ? await fetchAppStringGerman(parsed.messageKey) + : ''; + conditionEditorApi = mountConditionEditor(mount, { + questionnaireId: qid, + allQuestionnaires, + questionsByQuestionnaire, + conditionJson: condJson, + germanLabel, + editable: canEdit(), + }); +} + +// ── Save questionnaire meta ────────────────────────────────────────────── + +async function saveMeta() { + const name = document.getElementById('metaName').value.trim(); + const version = document.getElementById('metaVersion').value.trim(); + const state = document.getElementById('metaState').value; + const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10); + const showPoints = 0; + const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim(); + let conditionJson = '{}'; + let conditionForm = null; + if (conditionEditorApi) { + try { + conditionJson = conditionEditorApi.buildJson(); + conditionForm = conditionEditorApi.readForm(); + } catch (e) { + showToast(e.message || 'Invalid availability settings', 'error'); + return; + } + } + if (!name) { showToast('Name is required', 'error'); return; } + + try { + if (isNew) { + const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson, categoryKey }); + if (data.success) { + questionnaire = data.questionnaire; + isNew = false; + await saveConditionMessageLabel(conditionForm); + showToast('Questionnaire created', 'success'); + navigate(`#/questionnaire/${questionnaire.questionnaireID}`); + } + } else { + const data = await apiPut('questionnaires.php', { + questionnaireID: questionnaire.questionnaireID, + name, version, state, orderIndex, showPoints, conditionJson, categoryKey + }); + if (data.success) { + questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey }; + document.getElementById('editorTitle').textContent = name; + await saveConditionMessageLabel(conditionForm); + showToast('Saved', 'success'); + } + } + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function saveConditionMessageLabel(form) { + if (!form || form.mode === 'always') return; + const messageKey = (form.messageKey || '').trim(); + const germanLabel = (form.germanLabel || '').trim(); + if (!messageKey || !germanLabel) return; + try { + await apiPost('questionnaires.php', { + action: 'updateConditionMessageLabel', + messageKey, + germanLabel, + }); + } catch (e) { + showToast(`Saved questionnaire; message label failed: ${e.message}`, 'error'); + } +} + +// ── Add question form ──────────────────────────────────────────────────── + +function showAddQuestionForm() { + const wrapper = document.getElementById('addQuestionFormWrapper'); + if (!wrapper) return; + + const pendingOptions = []; + + wrapper.style.display = ''; + wrapper.innerHTML = ` +
      +

      New Question

      +
      +
      + + +
      +
      + + +
      +
      + + ${layoutSelectHTML('radio_question', 'aq_type')} +
      +
      + + +
      +
      ${notesSectionHTML({}, 'aq_cfg', '')}
      + +
      +
      + +
        +
        +
        + +
        +
        + +
        +
        + +
        +
        + + +
        + +
        +
        +
        + +
        + + +
        +
        + `; + + function updateTypeUI() { + const type = document.getElementById('aq_type').value; + const qKey = document.getElementById('aq_questionKey')?.value.trim() || ''; + document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none'; + document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg'); + wireStringSpinnerOtherToggle('aq_cfg'); + if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg'); + if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg'); + const notesEl = document.getElementById('aq_notes_section'); + if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey); + } + + function renderPendingOptions() { + const list = document.getElementById('aq_pending_options'); + if (!list) return; + if (!pendingOptions.length) { + list.innerHTML = `
      • No options yet.
      • `; + return; + } + list.innerHTML = pendingOptions.map((opt, i) => ` +
      • + ${esc(opt.optionKey)} + ${esc(opt.text)} + ${opt.points} pts + ${opt.nextQuestionId ? `→ ${esc(opt.nextQuestionId)}` : ''} + +
      • + `).join(''); + list.querySelectorAll('.remove-pending-opt').forEach(btn => { + btn.addEventListener('click', () => { + pendingOptions.splice(parseInt(btn.dataset.idx, 10), 1); + renderPendingOptions(); + }); + }); + } + + function addPendingOption() { + const optionKey = document.getElementById('aq_opt_key').value.trim(); + const text = document.getElementById('aq_opt_text').value.trim(); + const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10); + const next = document.getElementById('aq_opt_next').value; + if (!validateStableKey(optionKey, 'Option key')) return; + if (!text) { showToast('German label is required', 'error'); return; } + pendingOptions.push({ optionKey, text, points: pts, nextQuestionId: next }); + renderPendingOptions(); + document.getElementById('aq_opt_key').value = ''; + document.getElementById('aq_opt_text').value = ''; + document.getElementById('aq_opt_pts').value = '0'; + document.getElementById('aq_opt_next').value = ''; + document.getElementById('aq_opt_text').focus(); + } + + updateTypeUI(); + renderPendingOptions(); + document.getElementById('aq_text').focus(); + + document.getElementById('aq_type').addEventListener('change', updateTypeUI); + document.getElementById('aq_questionKey').addEventListener('input', () => { + const notesEl = document.getElementById('aq_notes_section'); + if (notesEl) { + const nb = document.getElementById('aq_cfg_noteBefore')?.value ?? ''; + const na = document.getElementById('aq_cfg_noteAfter')?.value ?? ''; + notesEl.innerHTML = notesSectionHTML( + { noteBefore: nb, noteAfter: na }, + 'aq_cfg', + document.getElementById('aq_questionKey').value.trim() + ); + } + }); + document.getElementById('aq_opt_add').addEventListener('click', addPendingOption); + document.getElementById('aq_opt_text').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); addPendingOption(); } + }); + + document.getElementById('aq_submit').addEventListener('click', async () => { + const questionKey = document.getElementById('aq_questionKey').value.trim(); + const text = document.getElementById('aq_text').value.trim(); + const type = document.getElementById('aq_type').value; + const isRequired = document.getElementById('aq_required').checked ? 1 : 0; + const btn = document.getElementById('aq_submit'); + const notes = readNotesFromForm('aq_cfg'); + const configJson = readConfigFromForm(type, 'aq_cfg'); + const postBody = { + questionnaireID: questionnaire.questionnaireID, + questionKey, + defaultText: text, + type, + isRequired, + configJson, + noteBefore: notes.noteBefore, + noteAfter: notes.noteAfter, + }; + if (type === 'glass_scale_question') { + const glassSymptoms = readGlassSymptomsFromForm('aq_cfg'); + if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return; + postBody.glassSymptoms = glassSymptoms; + } + if (type === 'slider_question' || type === 'value_spinner') { + postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg'); + } + if (!validateStableKey(questionKey, 'Question key')) return; + if (!text) { showToast('German text is required', 'error'); return; } + if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return; + if (OPTION_TYPES.has(type) && pendingOptions.length === 0) { + showToast('Add at least one answer option', 'error'); + return; + } + + btn.disabled = true; + btn.textContent = 'Adding...'; + try { + const qData = await apiPost('questions.php', postBody); + if (!qData.success) throw new Error(qData.error || 'Failed'); + + const newQ = qData.question; + newQ.answerOptions = []; + + for (const opt of pendingOptions) { + const oData = await apiPost('answer_options.php', { + questionID: newQ.questionID, + optionKey: opt.optionKey, + defaultText: opt.text, + points: opt.points, + nextQuestionId: opt.nextQuestionId || '', + }); + if (oData.success) newQ.answerOptions.push(oData.answerOption); + } + + questions.push(newQ); + renderQuestions(); + notifyStructureBump(qData); + showToast('Question added', 'success'); + + document.getElementById('aq_text').value = ''; + pendingOptions.length = 0; + renderPendingOptions(); + updateTypeUI(); + document.getElementById('aq_text').focus(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + if (document.getElementById('aq_submit')) { + btn.disabled = false; + btn.textContent = 'Add Question'; + } + } + }); + + document.getElementById('aq_text').addEventListener('keydown', (e) => { + if (e.key === 'Escape') hideAddQuestionForm(); + }); + document.getElementById('aq_cancel').addEventListener('click', hideAddQuestionForm); +} + +function hideAddQuestionForm() { + const wrapper = document.getElementById('addQuestionFormWrapper'); + if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } +} + +// ── Questions list ─────────────────────────────────────────────────────── + +function renderRetiredQuestions() { + const section = document.getElementById('retiredQuestionsSection'); + const list = document.getElementById('retiredQuestionList'); + const countEl = document.getElementById('retiredCount'); + if (!section || !list) return; + if (!retiredQuestions.length) { + section.style.display = 'none'; + list.innerHTML = ''; + if (countEl) countEl.textContent = '0'; + return; + } + section.style.display = ''; + if (countEl) countEl.textContent = String(retiredQuestions.length); + list.innerHTML = retiredQuestions.map((q, idx) => ` +
      • +
        + ${idx + 1}. ${esc(questionKeyOf(q) || '?')} · ${esc((q.defaultText || '').slice(0, 48))} + Retired + ${q.retiredInRevision ? `rev ${q.retiredInRevision}` : ''} +
        +
      • + `).join(''); +} + +function renderQuestions() { + const list = document.getElementById('questionList'); + if (!list) return; + const editable = canEdit(); + + // update tab label + const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]'); + if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`; + renderRetiredQuestions(); + + if (!questions.length) { + list.innerHTML = `
      • No questions yet

        Add your first question above.

      • `; + return; + } + + list.innerHTML = questions.map((q, idx) => ` +
      • +
        + ${editable ? '' : ''} + + ${idx + 1}. ${esc(questionKeyOf(q) || '?')} · ${esc((q.defaultText || '').slice(0, 48))}${(q.defaultText || '').length > 48 ? '…' : ''} + ${esc(layoutLabel(q.type))} + ${!questionKeyOf(q) ? 'No key' : ''} + ${q.isRequired ? 'Required' : ''} + ${(() => { + const c = parseConfig(q); + const parts = []; + if (c.nextQuestionId) parts.push(`→ ${esc(c.nextQuestionId)}`); + if (c.otherNextQuestionId) parts.push(`Other→${esc(c.otherNextQuestionId)}`); + return parts.length + ? `${esc(parts.join(' · '))}` : ''; + })()} +
        +
        +
      • + `).join(''); + + list.querySelectorAll('.question-header').forEach((hdr, idx) => { + hdr.addEventListener('click', (e) => { + if (e.target.closest('.drag-handle')) return; + const item = hdr.closest('.question-item'); + const wasOpen = item.classList.contains('open'); + item.classList.toggle('open'); + if (!wasOpen) renderQuestionBody(questions[idx]); + }); + }); +} + +// ── Question body (expanded) ───────────────────────────────────────────── + +function renderQuestionBody(q) { + const body = document.getElementById(`qbody-${q.questionID}`); + if (!body) return; + const editable = canEdit(); + const options = q.answerOptions || []; + const config = parseConfig(q); + const layout = q.type || 'radio_question'; + const qKey = questionKeyOf(q); + const localId = q.localId || q.questionID.split('__').pop(); + + body.innerHTML = ` + ${editable ? ` +
        +
        +

        Local ID: ${esc(localId)}

        +
        +
        + + +
        +
        + + +
        +
        + + ${layoutSelectHTML(layout, `eq_type_${q.questionID}`)} +
        +
        + + ${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)} +
        + ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { + glassSymptoms: glassSymptomsRowsFromQuestion(q), + scoreRules: q.scoreRules || [], + })} +
        +
        + + +
        +
        +
        + ` : ` +
        +

        Key: ${esc(qKey || '—')} · ID: ${esc(localId)}

        +

        German: ${esc(q.defaultText)}

        +

        Layout: ${esc(layoutLabel(layout))}

        + ${config.nextQuestionId || config.otherNextQuestionId + ? `

        Branches: + ${config.nextQuestionId ? `→ ${esc(config.nextQuestionId)}` : ''} + ${config.otherNextQuestionId ? `${config.nextQuestionId ? ' · ' : ''}Other (${esc(config.otherOptionKey || 'other_option')}) → ${esc(config.otherNextQuestionId)}` : ''} +

        ` + : ''} + ${Object.keys(config).length ? `

        Config: ${esc(JSON.stringify(config))}

        ` : ''} + ${layout === 'glass_scale_question' && glassSymptomsRowsFromQuestion(q).length ? ` +

        Symptoms:

        +
          + ${glassSymptomsRowsFromQuestion(q).map(r => + `
        • ${esc(r.key)} · ${esc(r.labelDe || '—')}
        • ` + ).join('')} +
        ` : ''} +
        + `} + + ${OPTION_TYPES.has(layout) ? ` +

        Answer Options (${options.length})

        +
          + ${options.map(o => optionItemHTML(q, o, editable)).join('') + || '
        • No answer options
        • '} +
        + ${editable ? ` + + + ` : ''} + ` : ''} + `; + + if (!editable) return; + + const cfgPrefix = `eq_cfg_${q.questionID}`; + + body.querySelector('.save-q-btn').addEventListener('click', () => { + const questionKey = document.getElementById(`eq_key_${q.questionID}`).value.trim(); + const text = document.getElementById(`eq_text_${q.questionID}`).value.trim(); + const type = document.getElementById(`eq_type_${q.questionID}`).value; + const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0; + const notes = readNotesFromForm(cfgPrefix); + const cj = readConfigFromForm(type, cfgPrefix); + const payload = { + questionKey, + defaultText: text, + type, + isRequired: isReq, + configJson: cj, + noteBefore: notes.noteBefore, + noteAfter: notes.noteAfter, + }; + if (type === 'glass_scale_question') { + const glassSymptoms = readGlassSymptomsFromForm(cfgPrefix); + if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return; + payload.glassSymptoms = glassSymptoms; + } + if (type === 'slider_question' || type === 'value_spinner') { + payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix); + } + if (!validateStableKey(questionKey, 'Question key')) return; + if (!text) { showToast('German text is required', 'error'); return; } + if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return; + updateQuestion(q.questionID, payload); + }); + + document.getElementById(`eq_key_${q.questionID}`).addEventListener('input', () => { + const nb = document.getElementById(`${cfgPrefix}_noteBefore`)?.value ?? ''; + const na = document.getElementById(`${cfgPrefix}_noteAfter`)?.value ?? ''; + const key = document.getElementById(`eq_key_${q.questionID}`).value.trim(); + const notesBlock = body.querySelector('.notes-section'); + if (notesBlock) { + notesBlock.outerHTML = notesSectionHTML({ noteBefore: nb, noteAfter: na }, cfgPrefix, key); + } + }); + + document.getElementById(`eq_type_${q.questionID}`).addEventListener('change', () => { + const newLayout = document.getElementById(`eq_type_${q.questionID}`).value; + const opts = newLayout === 'glass_scale_question' + ? { glassSymptoms: glassSymptomsRowsFromQuestion(q) } + : (newLayout === 'slider_question' || newLayout === 'value_spinner') + ? { scoreRules: q.scoreRules || [] } + : {}; + document.getElementById(`eq_config_${q.questionID}`).innerHTML = + configFormHTML(newLayout, {}, cfgPrefix, opts); + wireStringSpinnerOtherToggle(cfgPrefix); + if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix); + if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix); + }); + + wireStringSpinnerOtherToggle(cfgPrefix); + if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix); + if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix); + + body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q)); + + body.querySelectorAll('.edit-opt-btn').forEach(btn => { + btn.addEventListener('click', () => { + const opt = options.find(o => o.answerOptionID === btn.dataset.id); + if (opt) showEditOptionForm(q, opt); + }); + }); + body.querySelectorAll('.del-opt-btn').forEach(btn => { + btn.addEventListener('click', () => deleteOption(q, btn.dataset.id)); + }); + + const addOptBtn = document.getElementById(`addOpt-${q.questionID}`); + if (addOptBtn) addOptBtn.addEventListener('click', () => showAddOptionForm(q)); + + if (OPTION_TYPES.has(layout)) initOptionDrag(q); +} + +function optionItemHTML(q, o, editable) { + const ok = optionKeyOf(o); + const label = optionGermanLabel(o) || o.defaultText; + return ` +
      • + ${editable ? '' : ''} + ${esc(ok || '?')} + ${esc(label)} + ${o.points} pts + ${o.nextQuestionId ? `→ ${esc(o.nextQuestionId)}` : ''} + ${editable ? ` + + + ` : ''} +
      • + `; +} + +// ── Add option form ────────────────────────────────────────────────────── + +function showAddOptionForm(q) { + const wrapper = document.getElementById(`addOptForm-${q.questionID}`); + const toggleBtn = document.getElementById(`addOpt-${q.questionID}`); + if (!wrapper) return; + toggleBtn.style.display = 'none'; + wrapper.style.display = ''; + + const qIds = questionLocalIds(); + + wrapper.innerHTML = ` +
        +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + + +
        +
        + `; + + document.getElementById(`ao_text_${q.questionID}`).focus(); + + document.getElementById(`ao_submit_${q.questionID}`).addEventListener('click', async () => { + const optionKey = document.getElementById(`ao_key_${q.questionID}`).value.trim(); + const text = document.getElementById(`ao_text_${q.questionID}`).value.trim(); + const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10); + const nextQ = document.getElementById(`ao_next_${q.questionID}`).value; + if (!validateStableKey(optionKey, 'Option key')) return; + if (!text) { showToast('German label is required', 'error'); return; } + + const btn = document.getElementById(`ao_submit_${q.questionID}`); + btn.disabled = true; + btn.textContent = 'Adding...'; + try { + const data = await apiPost('answer_options.php', { + questionID: q.questionID, + optionKey, + defaultText: text, + points, + nextQuestionId: nextQ, + }); + if (data.success) { + if (!q.answerOptions) q.answerOptions = []; + q.answerOptions.push(data.answerOption); + renderQuestionBody(q); + showToast('Option added', 'success'); + } + } catch (e) { + showToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Add Option'; + } + }); + + document.getElementById(`ao_text_${q.questionID}`).addEventListener('keydown', (e) => { + if (e.key === 'Enter') document.getElementById(`ao_submit_${q.questionID}`).click(); + if (e.key === 'Escape') hideAddOptionForm(q); + }); + document.getElementById(`ao_cancel_${q.questionID}`).addEventListener('click', () => hideAddOptionForm(q)); +} + +function hideAddOptionForm(q) { + const wrapper = document.getElementById(`addOptForm-${q.questionID}`); + const toggleBtn = document.getElementById(`addOpt-${q.questionID}`); + if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } + if (toggleBtn) toggleBtn.style.display = ''; +} + +// ── Edit option form ───────────────────────────────────────────────────── + +function showEditOptionForm(q, opt) { + const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`); + if (!li) return; + const qIds = questionLocalIds(); + + li.innerHTML = ` +
        +
        +
        + + +
        +
        + + +
        +
        + +
        +
        + + +
        +
        +
        + + +
        +
        + `; + li.draggable = false; + + document.getElementById(`eo_text_${opt.answerOptionID}`).focus(); + + document.getElementById(`eo_save_${opt.answerOptionID}`).addEventListener('click', async () => { + const optionKey = document.getElementById(`eo_key_${opt.answerOptionID}`).value.trim(); + const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim(); + const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10); + const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value; + if (!validateStableKey(optionKey, 'Option key')) return; + if (!text) { showToast('German label is required', 'error'); return; } + await updateOption(q, opt.answerOptionID, { optionKey, defaultText: text, points, nextQuestionId: nextQ }); + }); + + document.getElementById(`eo_text_${opt.answerOptionID}`).addEventListener('keydown', (e) => { + if (e.key === 'Enter') document.getElementById(`eo_save_${opt.answerOptionID}`).click(); + if (e.key === 'Escape') renderQuestionBody(q); + }); + document.getElementById(`eo_cancel_${opt.answerOptionID}`).addEventListener('click', () => renderQuestionBody(q)); +} + +// ── Question CRUD ──────────────────────────────────────────────────────── + +async function updateQuestion(questionID, fields) { + try { + const data = await apiPut('questions.php', { questionID, ...fields }); + if (data.success) { + const idx = questions.findIndex(q => q.questionID === questionID); + if (idx >= 0) { + questions[idx] = { ...questions[idx], ...data.question, + answerOptions: questions[idx].answerOptions }; + } + renderQuestions(); + showToast('Question updated', 'success'); + } + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function deleteQuestion(q) { + if (q.retired) { + showToast('This question is already retired', 'error'); + return; + } + const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.'; + if (!(await confirmAction({ + title: 'Remove question', + message: msg, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; + try { + const data = await apiDelete('questions.php', { questionID: q.questionID }); + questions = questions.filter(x => x.questionID !== q.questionID); + if (data.retired) { + retiredQuestions.push({ ...q, retired: true, retiredAt: Date.now() / 1000 | 0 }); + showToast('Question retired', 'success'); + } else { + showToast('Question deleted', 'success'); + } + notifyStructureBump(data); + renderQuestions(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +// ── Answer option CRUD ─────────────────────────────────────────────────── + +async function updateOption(q, answerOptionID, fields) { + try { + const data = await apiPut('answer_options.php', { answerOptionID, ...fields }); + if (data.success) { + const idx = q.answerOptions.findIndex(o => o.answerOptionID === answerOptionID); + if (idx >= 0) q.answerOptions[idx] = { ...q.answerOptions[idx], ...data.answerOption }; + renderQuestionBody(q); + showToast('Option updated', 'success'); + } + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function deleteOption(q, answerOptionID) { + if (!(await confirmAction({ + title: 'Remove answer option', + message: 'Remove this answer option?', + confirmLabel: 'Remove', + variant: 'danger', + }))) return; + try { + const data = await apiDelete('answer_options.php', { answerOptionID }); + if (!data.retired) { + q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID); + } + renderQuestionBody(q); + notifyStructureBump(data); + showToast(data.retired ? 'Option retired' : 'Option deleted', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } +} + +// ── Translations tab ───────────────────────────────────────────────────── + +let transData = null; +let editorTransLang = ''; +let editorTransSaveTimers = {}; + +async function loadTranslationsTab() { + const container = document.getElementById('translationsContent'); + if (!container) return; + container.innerHTML = '
        '; + + try { + try { + await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' }); + } catch (_) { /* already exists */ } + const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`); + transData = data; + transData.entries = (transData.entries || []).map(normalizeEntry); + renderTranslationsTab(); + } catch (e) { + container.innerHTML = `

        ${esc(e.message)}

        `; + } +} + +function renderTranslationsTab() { + const container = document.getElementById('translationsContent'); + if (!container || !transData) return; + const editable = canEdit(); + const languages = transData.languages || []; + const qnEntries = transData.entries || []; + const targets = targetLanguages(languages); + + if (!qnEntries.length) { + container.innerHTML = ` + ${editable ? languageManagerHTML(languages) : ''} +

        No translatable content yet. Add questions with German text first.

        + `; + if (editable) bindLanguageManager(languages); + return; + } + + if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) { + editorTransLang = targets[0]?.languageCode || ''; + } + + const langSelectHtml = targets.length + ? `` + : `

        Add a language below (e.g. English) to translate into.

        `; + + container.innerHTML = ` + ${editable ? languageManagerHTML(languages) : ''} + ${langSelectHtml} + ${targets.length && editorTransLang ? renderEditorTransList(qnEntries, editable) : ''} + `; + + if (editable) bindLanguageManager(languages); + + document.getElementById('editorTransLangSelect')?.addEventListener('change', (e) => { + editorTransLang = e.target.value; + renderTranslationsTab(); + }); + + if (targets.length && editorTransLang) { + bindEditorTransEditing(qnEntries, editable); + bindEditorTransFiltering(qnEntries); + } +} + +function renderEditorTransList(qnEntries, editable) { + const targets = targetLanguages(transData.languages || []); + const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase(); + const sourceLabel = sourceLanguageLabel(transData.languages || []); + const missingCount = qnEntries.filter(e => !translationFor(e, editorTransLang).trim()).length; + const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0; + + const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0); + const bodyHtml = renderCollapsibleGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, { + openGroupsWithMissing: true, + }); + + return ` +
        + + +
        +
        + ${translationListHeaderHTML(langLabel, sourceLabel)} +
        +
        ${bodyHtml}
        +
        +
        +
        + ${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys + + + ${pct}% in ${escHtml(editorTransLang.toUpperCase())} + +
        + `; +} + +function bindEditorTransEditing(entries, editable) { + if (!editable) return; + editorTransSaveTimers = {}; + document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => { + inp.addEventListener('input', () => { + const idx = parseInt(inp.dataset.idx, 10); + const entry = entries[idx]; + if (!entry) return; + const isGerman = inp.dataset.field === 'german'; + if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {}; + if (isGerman) { + entry.germanText = inp.value; + entry.translations[SOURCE_LANG] = inp.value; + } else { + entry.translations[editorTransLang] = inp.value; + const missing = !inp.value.trim(); + inp.classList.toggle('trans-input-missing', missing); + const row = inp.closest('.trans-list-item'); + if (row) { + row.classList.toggle('trans-list-item-missing', missing); + row.dataset.missing = missing ? '1' : '0'; + } + } + const timerKey = isGerman ? `${idx}_de` : `${idx}_${editorTransLang}`; + clearTimeout(editorTransSaveTimers[timerKey]); + editorTransSaveTimers[timerKey] = setTimeout(() => saveEditorTranslation(entry, inp, isGerman), 500); + }); + }); +} + +async function saveEditorTranslation(entry, inp, isGerman) { + try { + await apiPut('translations.php', { + type: entry.type, + id: entry.entityId, + languageCode: isGerman ? SOURCE_LANG : editorTransLang, + text: inp.value, + }); + inp.classList.add('save-flash'); + setTimeout(() => inp.classList.remove('save-flash'), 400); + } catch (e) { + showToast(e.message, 'error'); + } +} + +function bindEditorTransFiltering(entries) { + const filterInput = document.getElementById('transFilter'); + const typeFilter = document.getElementById('transTypeFilter'); + const root = document.getElementById('transGroupedRoot'); + if (!filterInput || !root) return; + + function applyFilter() { + const text = filterInput.value.toLowerCase(); + const type = typeFilter?.value || ''; + let visible = 0; + let visibleMissing = 0; + root.querySelectorAll('.trans-list-item').forEach(row => { + const key = row.dataset.key?.toLowerCase() || ''; + const label = row.dataset.label?.toLowerCase() || ''; + const rowType = row.dataset.type || ''; + const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase() + || row.querySelector('[data-field="german"]')?.value?.toLowerCase() || ''; + const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || ''; + const show = (!type || rowType === type) && + (!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text)); + row.classList.toggle('trans-row-hidden', !show); + if (show) { + visible++; + if (row.dataset.missing === '1') visibleMissing++; + } + }); + root.querySelectorAll('.trans-group-details').forEach(group => { + const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); + group.classList.toggle('trans-group-hidden', !anyVisible); + }); + root.querySelectorAll('.trans-scope-block').forEach(block => { + const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); + block.classList.toggle('trans-qn-hidden', !anyVisible); + }); + const stats = document.getElementById('transStats'); + if (stats) { + stats.textContent = text || type + ? `Showing ${visible} (${visibleMissing} missing)` + : `${[...root.querySelectorAll('.trans-list-item')].filter(r => r.dataset.missing === '1').length} missing · ${entries.length} keys`; + } + } + + filterInput.addEventListener('input', applyFilter); + typeFilter?.addEventListener('change', applyFilter); +} + +function languageManagerHTML(languages) { + const others = targetLanguages(languages); + return ` +
        +

        + German source text is edited under Questions. Add other languages here. +

        +
        + + DE + German (source) + + ${others.map(l => ` + + ${esc(l.languageCode.toUpperCase())} + ${l.name ? `${esc(l.name)}` : ''} + + + `).join('')} +
        +
        + + + +
        +
        + `; +} + +function bindLanguageManager(languages) { + document.getElementById('addLangBtn')?.addEventListener('click', async () => { + const code = document.getElementById('newLangCode').value.trim().toLowerCase(); + const name = document.getElementById('newLangName').value.trim(); + if (!code) { showToast('Language code is required', 'error'); return; } + if (code === SOURCE_LANG) { + showToast('German (de) is always available as the source language', 'error'); + return; + } + if (languages.some(l => l.languageCode === code)) { + showToast('Language already exists', 'error'); + return; + } + try { + await apiPut('translations.php', { type: 'language', languageCode: code, name }); + showToast(`Language "${code}" added`, 'success'); + editorTransLang = code; + loadTranslationsTab(); + } catch (e) { + showToast(e.message, 'error'); + } + }); + + document.querySelectorAll('.lang-chip-remove').forEach(btn => { + btn.addEventListener('click', async () => { + const lc = btn.dataset.lc; + if (!(await confirmAction({ + title: 'Remove language', + message: `Remove language "${lc}" and ALL its translations?`, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; + try { + await apiDelete('translations.php', { type: 'language', languageCode: lc }); + showToast(`Language "${lc}" removed`, 'success'); + loadTranslationsTab(); + } catch (e) { + showToast(e.message, 'error'); + } + }); + }); +} + +// ── Drag & drop (questions) ────────────────────────────────────────────── + +function initDragReorder() { + const list = document.getElementById('questionList'); + if (!list) return; + let dragItem = null; + + list.addEventListener('dragstart', (e) => { + const item = e.target.closest('.question-item'); + if (!item || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } + dragItem = item; + item.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + list.addEventListener('dragover', (e) => { + e.preventDefault(); + const afterEl = getDragAfterElement(list, e.clientY, '.question-item'); + if (afterEl) list.insertBefore(dragItem, afterEl); + else list.appendChild(dragItem); + }); + + list.addEventListener('dragend', async () => { + if (!dragItem) return; + dragItem.classList.remove('dragging'); + const newOrder = [...list.querySelectorAll('.question-item')].map(el => el.dataset.id); + dragItem = null; + + const map = {}; + questions.forEach(q => map[q.questionID] = q); + questions = newOrder.map(id => map[id]).filter(Boolean); + + try { + await apiPatch('questions.php', { + questionnaireID: questionnaire.questionnaireID, + order: newOrder + }); + showToast('Order saved', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } + }); +} + +function initOptionDrag(q) { + const list = document.getElementById(`opts-${q.questionID}`); + if (!list) return; + let dragItem = null; + + list.addEventListener('dragstart', (e) => { + const item = e.target.closest('.option-item'); + if (!item || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } + dragItem = item; + item.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + list.addEventListener('dragover', (e) => { + e.preventDefault(); + const afterEl = getDragAfterElement(list, e.clientY, '.option-item'); + if (afterEl) list.insertBefore(dragItem, afterEl); + else list.appendChild(dragItem); + }); + + list.addEventListener('dragend', async () => { + if (!dragItem) return; + dragItem.classList.remove('dragging'); + const newOrder = [...list.querySelectorAll('.option-item')].map(el => el.dataset.id); + dragItem = null; + + const map = {}; + (q.answerOptions || []).forEach(o => map[o.answerOptionID] = o); + q.answerOptions = newOrder.map(id => map[id]).filter(Boolean); + + try { + await apiPatch('answer_options.php', { + questionID: q.questionID, + order: newOrder + }); + showToast('Option order saved', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } + }); +} + +function getDragAfterElement(container, y, selector) { + const elements = [...container.querySelectorAll(`${selector}:not(.dragging)`)]; + return elements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) return { offset, element: child }; + return closest; + }, { offset: Number.NEGATIVE_INFINITY }).element; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/export.js b/website/js/pages/export.js new file mode 100644 index 0000000..314f8d9 --- /dev/null +++ b/website/js/pages/export.js @@ -0,0 +1,233 @@ +import { apiGet, apiDownloadFetch, apiUrl } from '../api.js'; +import { canEdit, pageHeaderHTML, showToast } from '../app.js'; +let questionnairesList = []; +let filterSearch = ''; + +export async function exportPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + ${pageHeaderHTML('Export Data')} +
        + `; + + try { + const data = await apiGet('questionnaires.php'); + questionnairesList = data.questionnaires || []; + renderExport(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('exportContent').innerHTML = `

        ${esc(e.message)}

        `; + } +} + +function renderExport() { + const container = document.getElementById('exportContent'); + + if (!questionnairesList.length) { + container.innerHTML = ` +
        +

        No questionnaires

        +

        Create questionnaires first to export data.

        +
        + `; + return; + } + + const bundleBlock = canEdit() ? ` +
        +

        Export questionnaires (JSON)

        +

        + Download all questionnaires with questions, answer options, and every translation. + Use Import questionnaires on the dashboard to restore this file on another server. +

        + +
        + ` : ''; + + container.innerHTML = ` + ${bundleBlock} +
        +

        + Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility). +

        +
        + + +
        +
        + + + + + + + + + + + + +
        QuestionnaireVersionStateQuestionsCompletedActions
        +
        +
        + `; + + document.getElementById('exportListSearch').addEventListener('input', (e) => { + filterSearch = e.target.value; + renderExportTableBody(); + }); + + renderExportTableBody(); + wireExportActions(); +} + +function exportRowSearchText(q) { + return [q.name, q.questionnaireID, q.version, q.state].filter(Boolean).join(' '); +} + +function renderExportTableBody() { + const body = document.getElementById('exportTableBody'); + const countEl = document.getElementById('exportListCount'); + if (!body) return; + + const q = filterSearch.trim().toLowerCase(); + const filtered = q + ? questionnairesList.filter(item => exportRowSearchText(item).toLowerCase().includes(q)) + : questionnairesList; + + if (!filtered.length) { + body.innerHTML = ` + + + No questionnaires match + + `; + if (countEl) { + countEl.textContent = q + ? `0 of ${questionnairesList.length} questionnaires` + : ''; + } + return; + } + + body.innerHTML = filtered.map(item => exportRowHTML(item)).join(''); + + if (countEl) { + countEl.textContent = q + ? `${filtered.length} of ${questionnairesList.length} questionnaires` + : `${questionnairesList.length} questionnaire${questionnairesList.length === 1 ? '' : 's'}`; + } + + body.querySelectorAll('.export-btn').forEach(btn => { + btn.addEventListener('click', onExportCsvClick); + }); + body.querySelectorAll('.export-btn-all').forEach(btn => { + btn.addEventListener('click', onExportAllVersionsClick); + }); +} + +function exportRowHTML(q) { + return ` + + ${esc(q.name)} + ${esc(q.version || '—')} + ${esc(q.state || 'draft')} + ${q.questionCount || 0} + ${q.completedCount ?? 0} + + + + Results + + `; +} + +function wireExportActions() { + document.getElementById('exportBundleBtn')?.addEventListener('click', async () => { + const btn = document.getElementById('exportBundleBtn'); + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Preparing…'; + try { + const res = await apiDownloadFetch(apiUrl('export?bundle=1')); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || err.error || 'Export failed'); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const stamp = new Date().toISOString().slice(0, 10); + a.download = `questionnaires_bundle_${stamp}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast('Questionnaire bundle downloaded', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); +} + +async function downloadExportCsv(btn, url, filename) { + const res = await apiDownloadFetch(url); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Download failed' })); + throw new Error(err.error?.message || err.error || 'Download failed'); + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + a.click(); + URL.revokeObjectURL(blobUrl); +} + +async function onExportCsvClick(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Downloading…'; + try { + const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}`); + await downloadExportCsv(btn, url, `${btn.dataset.name}_v${btn.dataset.version}.csv`); + showToast('Download started', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } +} + +async function onExportAllVersionsClick(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Downloading…'; + try { + const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`); + const stamp = new Date().toISOString().slice(0, 10); + await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`); + showToast('All versions download started', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/home.js b/website/js/pages/home.js new file mode 100644 index 0000000..8d30e2a --- /dev/null +++ b/website/js/pages/home.js @@ -0,0 +1,108 @@ +import { apiGet } from '../api.js'; +import { getRole, pageHeaderHTML, showToast } from '../app.js'; + +export async function homeDashboardPage() { + const app = document.getElementById('app'); + const role = getRole(); + const isAdmin = role === 'admin'; + + app.innerHTML = ` + ${pageHeaderHTML('Overview', '', { + subtitle: `Ressourcenbarometer administration for your ${role === 'admin' ? 'organization' : 'supervised counselors'}`, + })} +
        + `; + + try { + const ov = await apiGet('analytics.php?overview=1'); + renderHomeDashboard(ov, isAdmin); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('homeDashboardContent').innerHTML = + `

        ${esc(e.message)}

        `; + } +} + +function renderHomeDashboard(ov, isAdmin) { + const el = document.getElementById('homeDashboardContent'); + + const qPreview = (ov.questionnaires || []).slice(0, 6).map(q => ` + + ${esc(q.name)} + ${q.completedCount ?? 0} / ${q.eligibleCount ?? 0} + ${q.completionRatio ?? 0}% + + `).join(''); + + const links = [ + { href: '#/questionnaires', label: 'Session measures', desc: 'Edit instruments, order, and categories' }, + { href: '#/clients', label: 'Clients', desc: 'Client codes, counselors, response history' }, + { href: '#/coaches', label: 'Counselor activity', desc: 'Uploads and workload per counselor' }, + { href: '#/insights', label: 'Insights', desc: 'Charts, completion rates, follow-up' }, + { href: '#/export', label: 'Export data', desc: 'Download CSV and bundles' }, + { href: '#/assignments', label: 'Assign clients', desc: 'Move clients between counselors' }, + { href: '#/users', label: 'Users', desc: 'Admins, supervisors, and counselors' }, + { href: '#/translations', label: 'Translations', desc: 'German source and other languages' }, + ]; + if (isAdmin) { + links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' }); + } + + el.innerHTML = ` + + +
        +

        Quick links

        + +
        + +
        +
        +

        Measure completion

        + Full report +
        + ${(ov.questionnaires || []).length ? ` +
        + + + + + ${qPreview}${(ov.questionnaires.length > 6) + ? `` + : ''} +
        Session measureCompletedRate
        + ${ov.questionnaires.length - 6} more — see Insights
        +
        + ` : '

        No active session measures.

        '} +
        + `; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/insights.js b/website/js/pages/insights.js new file mode 100644 index 0000000..790699c --- /dev/null +++ b/website/js/pages/insights.js @@ -0,0 +1,376 @@ +import { apiGet, apiPut } from '../api.js'; +import { pageHeaderHTML, showToast } from '../app.js'; +import { clientTableActionsHTML, confirmAndPatchClientArchive } from '../client-archive.js'; + +let overview = null; +let staleClients = []; +let followupSearch = ''; + +export async function insightsPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + ${pageHeaderHTML('Insights')} +
        + `; + + try { + const [ov, stale] = await Promise.all([ + apiGet('analytics.php?overview=1'), + apiGet('analytics.php?staleClients=1'), + ]); + overview = ov; + staleClients = stale.clients || []; + renderInsights(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('insightsContent').innerHTML = + `

        ${esc(e.message)}

        `; + } +} + +function renderInsights() { + const el = document.getElementById('insightsContent'); + const ov = overview || {}; + + const kpiCards = ` +
        +
        +
        ${ov.clientCount ?? 0}
        +
        Clients
        +
        +
        +
        ${ov.clientsWithCompletions ?? 0}
        +
        With completions
        +
        +
        +
        ${ov.submissionsLast7d ?? 0}
        +
        Uploads (7 days)
        +
        +
        +
        ${ov.submissionsLast30d ?? 0}
        +
        Uploads (30 days)
        +
        +
        + `; + + const uploadChart = verticalBarChart( + (ov.submissionsByDay || []).map(d => ({ + label: formatChartDay(d.date), + value: d.count, + title: `${d.date}: ${d.count} upload(s)`, + })), + { emptyLabel: 'No uploads in this period' } + ); + + const withoutCompletions = Math.max(0, (ov.clientCount ?? 0) - (ov.clientsWithCompletions ?? 0)); + const engagementChart = horizontalBarChart([ + { label: 'With completions', value: ov.clientsWithCompletions ?? 0 }, + { label: 'No completions yet', value: withoutCompletions }, + ], { suffix: '' }); + + const idleChart = horizontalBarChart(idleBucketItems(staleClients), { + suffix: '', + variant: 'warn', + }); + + const completionChart = horizontalBarChart( + (ov.questionnaires || []).map(q => ({ + label: q.name, + value: q.completionRatio ?? 0, + title: `${q.name}: ${q.completionRatio ?? 0}%`, + })), + { suffix: '%', max: 100 } + ); + + const scoringProfiles = ov.scoringProfiles || []; + const scoringCharts = scoringProfiles.map(sp => { + const computed = sp.computedBands || sp.bands || {}; + const coach = sp.coachBands || {}; + const computedChart = horizontalBarChart([ + { label: 'Green', value: computed.green ?? 0 }, + { label: 'Yellow', value: computed.yellow ?? 0 }, + { label: 'Red', value: computed.red ?? 0 }, + ], { suffix: '' }); + const coachChart = horizontalBarChart([ + { label: 'Green', value: coach.green ?? 0 }, + { label: 'Yellow', value: coach.yellow ?? 0 }, + { label: 'Red', value: coach.red ?? 0 }, + ], { suffix: '' }); + return ` +
        +

        ${esc(sp.name)}

        +

        + ${sp.clientCount ?? 0} client(s) with complete profile + ${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''} + ${sp.pendingReview ? ` · ${sp.pendingReview} pending Counselor review` : ''} +

        +

        Calculated bands

        + ${computedChart} +

        Counselor decisions

        + ${coachChart} +
        `; + }).join(''); + + const qRows = (ov.questionnaires || []).map(q => ` + + ${esc(q.name)} + ${q.completedCount ?? 0} / ${q.eligibleCount ?? 0} + ${q.completionRatio ?? 0}% + + `).join(''); + + el.innerHTML = ` + ${kpiCards} +
        +
        +

        Uploads per day

        +

        Last 14 days

        + ${uploadChart} +
        +
        +

        Client engagement

        +

        Among clients in your scope

        + ${engagementChart} +
        +
        +

        Days since last activity

        +

        Clients with at least one completion

        + ${idleChart} +
        +
        + ${scoringProfiles.length ? ` +
        +

        Scoring profiles

        +

        Calculated vs Counselor band distribution among clients with a complete weighted score

        +
        ${scoringCharts}
        +
        ` : ''} +
        +

        Completion by questionnaire

        +

        Share of clients who completed each active questionnaire

        + ${completionChart} +
        + + + + + ${qRows || ''} +
        QuestionnaireCompletedRate
        No active questionnaires
        +
        +
        +
        +

        Needs follow-up

        +

        + Clients who completed at least one questionnaire, sorted by longest time since last activity. +

        +
        + + +
        +
        + + + + + + + + +
        ClientCounselorLast questionnaireCompleted atDays idleNote
        +
        +
        + `; + + document.getElementById('followupListSearch').addEventListener('input', (e) => { + followupSearch = e.target.value; + renderFollowupTableBody(); + }); + + renderFollowupTableBody(); +} + +function followupSearchText(c) { + return [ + c.clientCode, + c.coachUsername, + c.lastQuestionnaireName, + c.lastQuestionnaireID, + c.note, + c.lastCompletedAt, + c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '', + ].filter(Boolean).join(' '); +} + +function filteredFollowupClients() { + const q = followupSearch.trim().toLowerCase(); + if (!q) return staleClients; + return staleClients.filter(c => followupSearchText(c).toLowerCase().includes(q)); +} + +function followupRowHTML(c) { + const code = esc(c.clientCode); + return ` + + ${code} + ${esc(c.coachUsername)} + ${esc(c.lastQuestionnaireName || '—')} + ${esc(c.lastCompletedAt || '—')} + ${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'} + + +
        + +
        + + + ${clientTableActionsHTML({ clientCode: code, archived: false, showDelete: false })} + + `; +} + +function renderFollowupTableBody() { + const body = document.getElementById('followupTableBody'); + const countEl = document.getElementById('followupListCount'); + if (!body) return; + + const filtered = filteredFollowupClients(); + + if (!filtered.length) { + body.innerHTML = ` + + + ${staleClients.length ? 'No clients match' : 'No clients'} + + `; + if (countEl) { + countEl.textContent = followupSearch.trim() && staleClients.length + ? `0 of ${staleClients.length} clients` + : ''; + } + return; + } + + body.innerHTML = filtered.map(c => followupRowHTML(c)).join(''); + + if (countEl) { + const q = followupSearch.trim(); + countEl.textContent = q + ? `${filtered.length} of ${staleClients.length} clients` + : `${staleClients.length} client${staleClients.length === 1 ? '' : 's'}`; + } + + body.querySelectorAll('.save-note-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tr = btn.closest('tr'); + const ta = tr?.querySelector('.followup-note-input'); + saveNote(btn.dataset.client, ta); + }); + }); + + body.querySelectorAll('.archive-client-btn').forEach(btn => { + btn.addEventListener('click', () => archiveFromFollowup(btn.dataset.code)); + }); +} + +async function archiveFromFollowup(clientCode) { + const ok = await confirmAndPatchClientArchive(clientCode, true); + if (!ok) return; + staleClients = staleClients.filter(c => c.clientCode !== clientCode); + renderFollowupTableBody(); +} + +async function saveNote(clientCode, ta) { + if (!ta) return; + try { + await apiPut('analytics.php', { clientCode, note: ta.value.trim() }); + const row = staleClients.find(c => c.clientCode === clientCode); + if (row) row.note = ta.value.trim(); + showToast('Note saved', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function formatChartDay(isoDate) { + if (!isoDate) return ''; + const d = new Date(isoDate + 'T12:00:00'); + if (Number.isNaN(d.getTime())) return isoDate.slice(5); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function chartMax(values, fallbackMax = 1) { + const m = Math.max(0, ...values); + return m > 0 ? m : fallbackMax; +} + +function verticalBarChart(items, { emptyLabel = 'No data' } = {}) { + if (!items.length) { + return `

        ${esc(emptyLabel)}

        `; + } + const max = chartMax(items.map(i => i.value)); + return ` + `; +} + +function horizontalBarChart(items, { suffix = '%', max: fixedMax, variant = '' } = {}) { + if (!items.length) { + return '

        No data

        '; + } + const max = fixedMax ?? chartMax(items.map(i => i.value)); + const fillClass = variant === 'warn' ? 'insights-hchart-fill--warn' + : variant === 'muted' ? 'insights-hchart-fill--muted' + : ''; + return ` + `; +} + +function idleBucketItems(clients) { + const buckets = [ + { label: '0–7 days', value: 0 }, + { label: '8–30 days', value: 0 }, + { label: '31–90 days', value: 0 }, + { label: '90+ days', value: 0 }, + { label: 'No uploads yet', value: 0 }, + ]; + for (const c of clients) { + const d = c.daysSinceLastActivity; + if (d == null) buckets[4].value += 1; + else if (d <= 7) buckets[0].value += 1; + else if (d <= 30) buckets[1].value += 1; + else if (d <= 90) buckets[2].value += 1; + else buckets[3].value += 1; + } + return buckets.filter(b => b.value > 0); +} diff --git a/website/js/pages/login.js b/website/js/pages/login.js new file mode 100644 index 0000000..b6fd4e2 --- /dev/null +++ b/website/js/pages/login.js @@ -0,0 +1,268 @@ +import { navigate } from '../router.js'; +import { isLoggedIn, showToast } from '../app.js'; +import { apiGet, invalidateSessionCheck, apiUrl } from '../api.js'; + +const WEBSITE_ROLES = ['admin', 'supervisor']; + +function setFormBusy(formId, busy, busyLabel) { + const form = document.getElementById(formId); + if (!form) return; + const btn = form.querySelector('button[type="submit"]'); + if (!btn) return; + if (!btn.dataset.defaultLabel) { + btn.dataset.defaultLabel = btn.textContent.trim(); + } + btn.disabled = busy; + btn.textContent = busy ? busyLabel : btn.dataset.defaultLabel; + form.querySelectorAll('input').forEach((el) => { el.disabled = busy; }); +} + +function setButtonBusy(button, busy, busyLabel) { + if (!button) return; + if (!button.dataset.defaultLabel) { + button.dataset.defaultLabel = button.textContent.trim(); + } + button.disabled = busy; + button.textContent = busy ? busyLabel : button.dataset.defaultLabel; +} + +export async function loginPage() { + if (isLoggedIn()) { + try { + const data = await apiGet('session'); + if (data.success && data.valid && !data.mustChangePassword && WEBSITE_ROLES.includes(data.role)) { + if (data.user) localStorage.setItem('qdb_user', data.user); + if (data.role) localStorage.setItem('qdb_role', data.role); + navigate('#/'); + return; + } + } catch { + invalidateSessionCheck(); + } + localStorage.removeItem('qdb_token'); + localStorage.removeItem('qdb_user'); + localStorage.removeItem('qdb_role'); + invalidateSessionCheck(); + } + + const app = document.getElementById('app'); + app.innerHTML = ` + + `; + + let pendingUsername = ''; + let pendingTempToken = ''; + + const keycloakSection = document.getElementById('keycloakLoginSection'); + const keycloakBtn = document.getElementById('keycloakLoginBtn'); + const keycloakErr = document.getElementById('keycloakLoginError'); + let keycloakLoginUrl = ''; + + apiGet('auth/keycloak-config') + .then((data) => { + if (!data.success || !data.enabled) { + keycloakBtn.textContent = 'University login unavailable'; + return; + } + keycloakLoginUrl = data.loginUrl ? apiUrl(data.loginUrl) : apiUrl('auth/keycloak-login'); + keycloakBtn.textContent = `Log in with ${data.displayName || 'University Konstanz'}`; + keycloakBtn.disabled = false; + }) + .catch(() => { + keycloakBtn.textContent = 'University login unavailable'; + }); + + keycloakBtn.addEventListener('click', () => { + keycloakErr.hidden = true; + if (!keycloakLoginUrl) { + keycloakErr.textContent = 'University login is not available.'; + keycloakErr.hidden = false; + return; + } + setButtonBusy(keycloakBtn, true, 'Redirecting...'); + window.location.assign(keycloakLoginUrl); + }); + + document.getElementById('loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const errEl = document.getElementById('loginError'); + errEl.hidden = true; + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + + setFormBusy('loginForm', true, 'Signing in…'); + try { + const res = await fetch(apiUrl('auth/login'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-QDB-Client': 'web', + }, + body: JSON.stringify({ username, password }), + }); + const json = await res.json(); + if (!json.ok) { + const code = json.error?.code || ''; + let msg = json.error?.message || 'Login failed'; + if (res.status === 429 || code === 'RATE_LIMITED') { + const retry = res.headers.get('Retry-After'); + if (retry) { + msg += ` Try again in about ${retry} seconds.`; + } + } + errEl.textContent = msg; + errEl.hidden = false; + return; + } + const d = json.data; + if (d.role === 'coach') { + errEl.textContent = 'Counselor accounts can only sign in through the mobile app.'; + errEl.hidden = false; + return; + } + if (d.mustChangePassword) { + pendingUsername = username; + pendingTempToken = d.token; + document.getElementById('loginForm').hidden = true; + document.getElementById('changePwSection').hidden = false; + keycloakSection.hidden = true; + document.getElementById('login-panel-title').textContent = 'New password'; + document.getElementById('loginPanelSubtitle').textContent = pendingUsername; + document.getElementById('newPw').focus(); + return; + } + localStorage.setItem('qdb_token', d.token); + localStorage.setItem('qdb_user', d.user); + localStorage.setItem('qdb_role', d.role); + invalidateSessionCheck(); + navigate('#/'); + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } finally { + setFormBusy('loginForm', false, 'Signing in…'); + } + }); + + document.getElementById('changePwForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const errEl = document.getElementById('changePwError'); + errEl.hidden = true; + const newPw = document.getElementById('newPw').value; + const confirm = document.getElementById('newPwConfirm').value; + if (newPw !== confirm) { + errEl.textContent = 'Passwords do not match'; + errEl.hidden = false; + return; + } + const oldPw = document.getElementById('password').value; + + setFormBusy('changePwForm', true, 'Saving…'); + try { + const headers = { + 'Content-Type': 'application/json', + 'X-QDB-Client': 'web', + }; + if (pendingTempToken) { + headers['Authorization'] = `Bearer ${pendingTempToken}`; + } + const res = await fetch(apiUrl('auth/change-password'), { + method: 'POST', + headers, + body: JSON.stringify({ username: pendingUsername, old_password: oldPw, new_password: newPw }), + }); + const json = await res.json(); + if (!json.ok) { + errEl.textContent = json.error?.message || 'Password change failed'; + errEl.hidden = false; + return; + } + const d = json.data; + if (d.role === 'coach') { + errEl.textContent = 'Counselor accounts can only sign in through the mobile app.'; + errEl.hidden = false; + return; + } + localStorage.setItem('qdb_token', d.token); + localStorage.setItem('qdb_user', d.user); + localStorage.setItem('qdb_role', d.role); + invalidateSessionCheck(); + showToast('Password changed successfully', 'success'); + navigate('#/'); + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } finally { + setFormBusy('changePwForm', false, 'Saving…'); + } + }); +} diff --git a/website/js/pages/questionnaires.js b/website/js/pages/questionnaires.js new file mode 100644 index 0000000..c3db61b --- /dev/null +++ b/website/js/pages/questionnaires.js @@ -0,0 +1,832 @@ +import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; +import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; +import { navigate } from '../router.js'; + +export async function questionnairesPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + ${pageHeaderHTML('Session measures', '')} +
        +
        +
        + `; + + if (canEdit()) { + document.getElementById('headerActions').innerHTML = ` + + + + New Questionnaire + `; + bindImportBundle(); + } + + try { + const [data, profilesData] = await Promise.all([ + apiGet('questionnaires.php'), + apiGet('scoring_profiles.php').catch(() => ({ profiles: [] })), + ]); + const categoryKeys = data.categoryKeys || []; + renderGrid(data.questionnaires || [], categoryKeys); + renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []); + renderCategoryKeysPanel(categoryKeys); + bindCategoryLabelEditors(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('qGrid').innerHTML = `

        ${e.message}

        `; + } +} + +function renderCategoryKeysPanel(categoryKeys) { + const panel = document.getElementById('categoryKeysPanel'); + if (!panel) return; + + if (!canEdit()) { + panel.innerHTML = ''; + return; + } + + if (!categoryKeys.length) { + panel.innerHTML = ` +
        +

        Category keys

        +

        + Set a category key on a questionnaire (editor → settings) to group it on the app opening screen. + German group titles can be edited below once a key is in use. +

        +
        `; + return; + } + + panel.innerHTML = ` +
        +

        Category keys

        +

        + German labels for the app opening screen. Other languages: Translations. + Delete removes the key from questionnaires, all translations, and catalog defaults. +

        +
        + + + + + + + + + + + + ${categoryKeys.map(row => ` + + + + + + + + `).join('')} + +
        Category keyApp string keyGerman labelSession measures
        ${esc(row.categoryKey)}${esc(row.stringKey)} + + ${row.orphaned + ? 'unused' + : esc(String(row.questionnaireCount))} + +
        +
        +
        `; + + panel.querySelectorAll('.delete-cat-key-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const key = btn.dataset.key; + const count = parseInt(btn.dataset.count || '0', 10); + let msg = `Delete category key "${key}"?\n\nThis removes all translations and catalog defaults for questionnaire_group_${key}.`; + if (count > 0) { + msg += `\n\n${count} questionnaire(s) will have their category key cleared.`; + } + if (!(await confirmAction({ + title: 'Delete category key', + message: msg, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + btn.disabled = true; + try { + const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key }); + const cleared = result.questionnairesCleared ?? count; + showToast( + cleared + ? `Deleted "${key}" and cleared ${cleared} questionnaire(s)` + : `Deleted "${key}"`, + 'success' + ); + await questionnairesPage(); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + } + }); + }); +} + +function categoryGermanInputHTML(categoryKey, label) { + if (!canEdit() || !categoryKey) { + return esc(label); + } + return ``; +} + +function bindCategoryLabelEditors() { + if (!canEdit()) return; + + document.querySelectorAll('.category-german-input').forEach(input => { + input.dataset.lastSaved = input.value.trim(); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } + }); + + input.addEventListener('blur', () => saveCategoryGermanLabel(input)); + }); +} + +async function saveCategoryGermanLabel(input) { + const categoryKey = input.dataset.categoryKey?.trim(); + const label = input.value.trim(); + if (!categoryKey) return; + + const prev = input.dataset.lastSaved ?? ''; + if (label === prev) return; + + if (!label) { + showToast('German label cannot be empty', 'error'); + input.value = prev; + return; + } + + input.disabled = true; + try { + await apiPost('questionnaires.php', { + action: 'updateCategoryLabel', + categoryKey, + germanLabel: label, + }); + input.dataset.lastSaved = label; + syncCategoryGermanInputs(categoryKey, label); + showToast('Category label saved', 'success'); + } catch (err) { + showToast(err.message, 'error'); + input.value = prev; + } finally { + input.disabled = false; + } +} + +function syncCategoryGermanInputs(categoryKey, label) { + document.querySelectorAll('.category-german-input').forEach(el => { + if (el.dataset.categoryKey === categoryKey) { + el.value = label; + el.dataset.lastSaved = label; + } + }); +} + +const UNCATEGORIZED = '__uncategorized__'; + +function groupQuestionnairesByCategory(questionnaires, categoryKeys) { + const labelByKey = Object.fromEntries( + (categoryKeys || []) + .filter(r => !r.orphaned) + .map(r => [r.categoryKey, r.germanLabel || r.categoryKey]) + ); + + const sorted = [...questionnaires].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); + const buckets = new Map(); + + for (const q of sorted) { + const key = (q.categoryKey || '').trim() || UNCATEGORIZED; + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(q); + } + + const sections = [...buckets.entries()].map(([key, items]) => ({ + categoryKey: key === UNCATEGORIZED ? '' : key, + title: key === UNCATEGORIZED ? 'Uncategorized' : (labelByKey[key] || key), + items, + sortKey: Math.min(...items.map(q => q.orderIndex ?? 0)), + })); + + sections.sort((a, b) => { + const aUncat = a.categoryKey === ''; + const bUncat = b.categoryKey === ''; + if (aUncat !== bUncat) { + return aUncat ? 1 : -1; + } + return a.sortKey - b.sortKey; + }); + return sections; +} + +function renderQuestionnaireCard(q) { + const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`; + return ` +
        +
        +
        + ${canEdit() ? '' : ''} + ${esc(q.name)} +
        + ${esc(q.state || 'draft')} +
        +
        + v${esc(q.version || '—')} + ${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'} + #${q.orderIndex ?? '—'} +
        +
        + Edit + Results + ${getRole() === 'admin' ? `` : ''} +
        +
        `; +} + +function renderGrid(questionnaires, categoryKeys = []) { + const grid = document.getElementById('qGrid'); + + if (!questionnaires.length) { + grid.innerHTML = ` +
        + +

        No questionnaires yet

        +

        Create your first questionnaire to get started.

        +
        + `; + return; + } + + const sections = groupQuestionnairesByCategory(questionnaires, categoryKeys); + + grid.innerHTML = ` +
        + ${sections.map(section => ` +
        +

        ${categoryGermanInputHTML(section.categoryKey, section.title)}

        +
        + ${section.items.map(q => renderQuestionnaireCard(q)).join('')} +
        +
        + `).join('')} +
        `; + + grid.querySelectorAll('.q-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.q-card-actions') || e.target.closest('.drag-handle')) return; + navigate(`#/questionnaire/${card.dataset.id}`); + }); + }); + + grid.querySelectorAll('.delete-q-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (!(await confirmAction({ + title: 'Delete questionnaire', + message: 'Delete this questionnaire and all its data?', + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + try { + await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id }); + showToast('Questionnaire deleted', 'success'); + btn.closest('.q-card').remove(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + + if (canEdit()) initGridDrag(questionnaires); +} + +function collectDashboardCardOrder() { + const ids = []; + document.querySelectorAll('.q-category-group').forEach(section => { + section.querySelectorAll('.q-category-grid .q-card').forEach(card => { + ids.push(card.dataset.id); + }); + }); + return ids; +} + +function initGridDrag(questionnaires) { + const root = document.getElementById('qGrid'); + if (!root) return; + let dragItem = null; + let dragRow = null; + + root.addEventListener('dragstart', (e) => { + const card = e.target.closest('.q-card'); + if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } + dragItem = card; + dragRow = card.closest('.q-category-grid'); + card.style.opacity = '0.5'; + e.dataTransfer.effectAllowed = 'move'; + }); + + root.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!dragItem || !dragRow) return; + + const row = e.target.closest('.q-category-grid'); + if (!row || row !== dragRow) return; + + const cards = [...row.querySelectorAll('.q-card')].filter(c => c !== dragItem); + let insertBefore = null; + for (const child of cards) { + const box = child.getBoundingClientRect(); + if (e.clientX < box.left + box.width / 2) { + insertBefore = child; + break; + } + } + if (insertBefore) row.insertBefore(dragItem, insertBefore); + else row.appendChild(dragItem); + }); + + root.addEventListener('dragend', async () => { + if (!dragItem) return; + dragItem.style.opacity = ''; + const newOrder = collectDashboardCardOrder(); + dragItem = null; + dragRow = null; + + for (let i = 0; i < newOrder.length; i++) { + const id = newOrder[i]; + const q = questionnaires.find(x => x.questionnaireID === id); + if (q && parseInt(q.orderIndex, 10) !== i) { + try { + await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i }); + q.orderIndex = i; + } catch (err) { + showToast(err.message, 'error'); + return; + } + } + } + showToast('Order saved', 'success'); + }); +} + +function normalizeProfileBands(profile) { + const greenMax = profile?.greenMax ?? 12; + const yellowMax = profile?.yellowMax ?? 36; + return { + greenMin: profile?.greenMin ?? 0, + greenMax, + yellowMin: profile?.yellowMin ?? (greenMax + 1), + yellowMax, + redMin: profile?.redMin ?? (yellowMax + 1), + }; +} + +function bandSummary(profile) { + const b = normalizeProfileBands(profile || {}); + return `Green ${b.greenMin}–${b.greenMax} · Yellow ${b.yellowMin}–${b.yellowMax} · Red ${b.redMin}+`; +} + +function readBandsFromModal(overlay) { + return { + greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10), + greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10), + yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10), + yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10), + redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10), + }; +} + +function validateBands(b) { + if (b.greenMin > b.greenMax) return 'Green min must not exceed green max'; + if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max'; + if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts'; + if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts'; + return null; +} + +function bandPreviewHTML(b) { + return ` +
        + Green: ${b.greenMin} – ${b.greenMax} + Yellow: ${b.yellowMin} – ${b.yellowMax} + Red: ${b.redMin}+ +
        `; +} + +function bandRangesEditorHTML(bands) { + const b = normalizeProfileBands(bands); + return ` +

        Score bands

        +

        Set inclusive min/max for each band. Ranges must not overlap.

        +
        +
        + BandMinMax +
        +
        + Green + + +
        +
        + Yellow + + +
        +
        + Red + + +
        +
        +
        `; +} + +function renderScoringProfilesSection(profiles, questionnaires) { + const panel = document.getElementById('scoringProfilesPanel'); + if (!panel) return; + + panel.innerHTML = ` +
        +
        +
        +
        +

        Scoring profiles

        +

        + Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights. +

        +
        + ${canEdit() ? '' : ''} +
        +
        +
        +
        `; + + const list = panel.querySelector('#scoringProfilesList'); + if (!profiles.length) { + list.innerHTML = '

        No scoring profiles yet. Create one to combine questionnaire scores for insights.

        '; + } else { + list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join(''); + } + + if (canEdit()) { + panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => { + openScoringProfileModal(null, questionnaires, profiles); + }); + list.querySelectorAll('.edit-profile-btn').forEach(btn => { + btn.addEventListener('click', () => { + const profile = profiles.find(x => x.profileID === btn.dataset.id); + openScoringProfileModal(profile, questionnaires, profiles); + }); + }); + list.querySelectorAll('.delete-profile-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!(await confirmAction({ + title: 'Delete scoring profile', + message: `Delete scoring profile "${btn.dataset.name}"?`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + try { + await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id }); + showToast('Profile deleted', 'success'); + await questionnairesPage(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + } +} + +function scoringProfileCardHTML(profile, questionnaires) { + const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q])); + const members = profile.questionnaires || []; + const activeBadge = profile.isActive + ? 'Active' + : 'Inactive'; + const memberRows = members.map(m => { + const q = qnById[m.questionnaireID]; + const name = q?.name || m.questionnaireID; + return `
        ${esc(name)}× ${m.weight}
        `; + }).join(''); + + return ` +
        +
        +
        +
        + ${esc(profile.name)} + ${activeBadge} +
        + ${profile.description ? `

        ${esc(profile.description)}

        ` : ''} +

        ${bandSummary(profile)}

        +
        + ${canEdit() ? ` +
        + + +
        ` : ''} +
        +

        ${members.length} questionnaire(s)

        +
        ${memberRows || '

        No questionnaires linked

        '}
        +
        `; +} + +function openScoringProfileModal(profile, questionnaires, allProfiles) { + const isEdit = !!profile; + const members = profile?.questionnaires?.length + ? [...profile.questionnaires] + : []; + const selectedIds = new Set(members.map(m => m.questionnaireID)); + const initialBands = normalizeProfileBands(profile || {}); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + document.body.classList.add('modal-open'); + + const picker = overlay.querySelector('#spQnPicker'); + const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); + + function renderPicker() { + const ordered = members.length + ? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) })) + : []; + const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID)); + + picker.innerHTML = ` + ${ordered.map((m, idx) => ` +
        + ${esc(m.q?.name || m.questionnaireID)} + + + + + + +
        + `).join('')} + ${available.length ? ` +
        + + +
        ` : ''}`; + + picker.querySelectorAll('.sp-weight').forEach((input, idx) => { + input.addEventListener('change', () => { + members[idx].weight = parseFloat(input.value) || 1; + }); + }); + picker.querySelectorAll('.sp-remove').forEach(btn => { + btn.addEventListener('click', () => { + const row = btn.closest('.scoring-profile-q-row'); + const id = row?.dataset.qn; + if (!id) return; + selectedIds.delete(id); + const i = members.findIndex(m => m.questionnaireID === id); + if (i >= 0) members.splice(i, 1); + renderPicker(); + }); + }); + picker.querySelectorAll('.sp-move-up').forEach(btn => { + btn.addEventListener('click', () => { + const row = btn.closest('.scoring-profile-q-row'); + const id = row?.dataset.qn; + const i = members.findIndex(m => m.questionnaireID === id); + if (i > 0) { + [members[i - 1], members[i]] = [members[i], members[i - 1]]; + renderPicker(); + } + }); + }); + picker.querySelectorAll('.sp-move-down').forEach(btn => { + btn.addEventListener('click', () => { + const row = btn.closest('.scoring-profile-q-row'); + const id = row?.dataset.qn; + const i = members.findIndex(m => m.questionnaireID === id); + if (i >= 0 && i < members.length - 1) { + [members[i], members[i + 1]] = [members[i + 1], members[i]]; + renderPicker(); + } + }); + }); + overlay.querySelector('#spAddBtn')?.addEventListener('click', () => { + const sel = overlay.querySelector('#spAddSelect'); + const id = sel?.value; + if (!id || selectedIds.has(id)) return; + selectedIds.add(id); + members.push({ questionnaireID: id, weight: 1, orderIndex: members.length }); + renderPicker(); + }); + } + + function updateBandPreview() { + const el = overlay.querySelector('#spBandPreview'); + if (!el) return; + const b = readBandsFromModal(overlay); + const err = validateBands(b); + if (err) { + el.innerHTML = `

        ${esc(err)}

        `; + return; + } + el.innerHTML = bandPreviewHTML(b); + } + + renderPicker(); + updateBandPreview(); + ['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => { + overlay.querySelector(sel)?.addEventListener('input', updateBandPreview); + }); + + const close = () => { + overlay.remove(); + document.body.classList.remove('modal-open'); + }; + overlay.querySelector('#spCancel')?.addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + overlay.querySelector('#spSave')?.addEventListener('click', async () => { + const name = overlay.querySelector('#spName')?.value.trim(); + const description = overlay.querySelector('#spDesc')?.value.trim() || ''; + const isActive = overlay.querySelector('#spActive')?.checked; + const bands = readBandsFromModal(overlay); + const bandErr = validateBands(bands); + if (!name) { + showToast('Profile name is required', 'error'); + return; + } + if (bandErr) { + showToast(bandErr, 'error'); + return; + } + if (!members.length) { + showToast('Add at least one questionnaire', 'error'); + return; + } + const payload = { + name, + description, + isActive, + ...bands, + questionnaires: members.map((m, idx) => ({ + questionnaireID: m.questionnaireID, + weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1, + orderIndex: idx, + })), + }; + if (isEdit) payload.profileID = profile.profileID; + + const saveBtn = overlay.querySelector('#spSave'); + saveBtn.disabled = true; + try { + if (isEdit) { + await apiPut('scoring_profiles.php', payload); + } else { + await apiPost('scoring_profiles.php', payload); + } + showToast(isEdit ? 'Profile saved' : 'Profile created', 'success'); + close(); + await questionnairesPage(); + } catch (err) { + showToast(err.message, 'error'); + saveBtn.disabled = false; + } + }); +} + +function bindImportBundle() { + const fileInput = document.getElementById('importBundleFile'); + const btn = document.getElementById('importBundleBtn'); + if (!fileInput || !btn) return; + + btn.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + fileInput.value = ''; + if (!file) return; + + let bundle; + try { + const text = await file.text(); + bundle = JSON.parse(text); + } catch { + showToast('Invalid JSON file', 'error'); + return; + } + + if (!bundle?.questionnaires || !Array.isArray(bundle.questionnaires)) { + showToast('Not a questionnaire bundle (missing questionnaires array)', 'error'); + return; + } + + const count = bundle.questionnaires.length; + if (!(await confirmAction({ + title: 'Import questionnaires', + message: `Import ${count} questionnaire(s) from this file?`, + confirmLabel: 'Import', + }))) return; + + const mode = await confirmAction({ + title: 'Existing questionnaire IDs', + message: 'If a questionnaire ID from the file already exists, how should it be handled?', + choices: [ + { + value: 'replace', + label: 'Replace existing', + description: 'Overwrite the questionnaire with that ID.', + }, + { + value: 'copy', + label: 'Import as new copy', + description: 'Keep the existing questionnaire and import with a new ID.', + }, + ], + confirmLabel: 'Continue', + cancelLabel: 'Cancel import', + }); + if (!mode) return; + const replace = mode === 'replace'; + + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Importing…'; + try { + const result = await apiPost('questionnaires.php', { + action: 'importBundle', + bundle, + replaceIfExists: replace, + }); + const n = result.imported ?? count; + showToast(`Imported ${n} questionnaire(s)`, 'success'); + await questionnairesPage(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/results.js b/website/js/pages/results.js new file mode 100644 index 0000000..5ad7216 --- /dev/null +++ b/website/js/pages/results.js @@ -0,0 +1,468 @@ +import { apiGet } from '../api.js'; +import { homeNavButton, showToast } from '../app.js'; +import { buildResultColumns, resultColumnCellValue } from '../test-data.js'; + +const PAGE_SIZE = 50; + +let sortCol = null; +let sortDir = 'asc'; +let allClients = []; +let questionsDef = []; +let resultColumns = []; +let questionnaireMeta = null; +let filterCoach = ''; +let filterSupervisor = ''; +let filterStatus = ''; +let filterDateFrom = ''; +let filterDateTo = ''; +let filterSearch = ''; +let filterQuestion = ''; +let page = 0; + +export async function resultsPage(params) { + const app = document.getElementById('app'); + sortCol = null; + sortDir = 'asc'; + filterCoach = ''; + filterSupervisor = ''; + filterStatus = ''; + filterDateFrom = ''; + filterDateTo = ''; + filterSearch = ''; + filterQuestion = ''; + page = 0; + + app.innerHTML = ` + +
        + `; + + try { + const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`); + questionnaireMeta = data.questionnaire; + questionsDef = data.questions || []; + allClients = data.clients || []; + document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`; + renderResults(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('resultsContent').innerHTML = `

        ${esc(e.message)}

        `; + } +} + +function renderResults() { + const container = document.getElementById('resultsContent'); + resultColumns = buildResultColumns(questionsDef); + + // Collect unique coaches and statuses for filters + const coachOptions = getCoachFilterOptions(); + const supervisorOptions = getSupervisorFilterOptions(); + const statuses = [...new Set(allClients.map(c => c.status))].filter(Boolean).sort(); + + container.innerHTML = ` +
        +
        + + + + + + + + + + + + + Export CSV +
        +

        Each question has its own column; glass-scale symptoms are separate columns. Hover headers for context.

        +
        +
        +
        + + + +
        +
        +
        +
        + `; + + const qKeyList = document.getElementById('questionKeyList'); + qKeyList.innerHTML = resultColumns + .map(col => ``) + .join(''); + + document.getElementById('filterSupervisor').addEventListener('change', (e) => { + filterSupervisor = e.target.value; + page = 0; + renderTableRows(); + }); + document.getElementById('filterCoach').addEventListener('change', (e) => { + filterCoach = e.target.value; + page = 0; + renderTableRows(); + }); + document.getElementById('filterStatus').addEventListener('change', (e) => { + filterStatus = e.target.value; + page = 0; + renderTableRows(); + }); + document.getElementById('filterDateFrom').addEventListener('change', (e) => { + filterDateFrom = e.target.value; + page = 0; + renderTableRows(); + }); + document.getElementById('filterDateTo').addEventListener('change', (e) => { + filterDateTo = e.target.value; + page = 0; + renderTableRows(); + }); + document.getElementById('filterSearch').addEventListener('input', (e) => { + filterSearch = e.target.value.trim(); + page = 0; + renderTableRows(); + }); + document.getElementById('gotoQuestionBtn').addEventListener('click', () => { + filterQuestion = document.getElementById('filterQuestion').value.trim(); + scrollToQuestionColumn(filterQuestion); + }); + document.getElementById('filterQuestion').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + filterQuestion = e.target.value.trim(); + scrollToQuestionColumn(filterQuestion); + } + }); + document.getElementById('clearFiltersBtn').addEventListener('click', () => { + filterCoach = ''; + filterSupervisor = ''; + filterStatus = ''; + filterDateFrom = ''; + filterDateTo = ''; + filterSearch = ''; + filterQuestion = ''; + page = 0; + renderResults(); + }); + document.getElementById('exportCsvLink').addEventListener('click', (e) => { + e.preventDefault(); + exportCSV(); + }); + + renderTableHead(); + renderTableRows(); +} + +function renderTableHead() { + const head = document.getElementById('resultsHead'); + const fixedCols = [ + { key: 'clientCode', label: 'Client' }, + { key: 'coachUsername', label: 'Counselor' }, + { key: 'supervisorUsername', label: 'Supervisor' }, + { key: 'status', label: 'Status' }, + { key: 'sumPoints', label: 'Score' }, + { key: 'completedAt', label: 'Completed' }, + ]; + const allCols = [...fixedCols, ...resultColumns.map(col => ({ + key: col.key, + label: col.label, + title: col.title, + }))]; + + head.innerHTML = allCols.map((col, idx) => { + let cls = 'sortable'; + if (sortCol === col.key) cls += sortDir === 'asc' ? ' sort-asc' : ' sort-desc'; + if (idx === 0) cls += ' sticky-col'; + const title = col.title ? ` title="${esc(col.title)}"` : ''; + return `${esc(col.label)}`; + }).join(''); + + head.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.key; + if (sortCol === key) sortDir = sortDir === 'asc' ? 'desc' : 'asc'; + else { sortCol = key; sortDir = 'asc'; } + page = 0; + renderTableHead(); + renderTableRows(); + }); + }); +} + +function scrollToQuestionColumn(query) { + if (!query) return; + const q = query.toLowerCase(); + const th = [...document.querySelectorAll('#resultsHead th')].find(el => { + const key = (el.dataset.key || '').toLowerCase(); + const text = (el.textContent || '').trim().toLowerCase(); + const title = (el.getAttribute('title') || '').toLowerCase(); + return text === q || title === q || key.endsWith(q) || text.includes(q); + }); + if (!th) { + showToast(`No column matching "${query}"`, 'error'); + return; + } + th.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + th.style.outline = '2px solid var(--primary)'; + setTimeout(() => { th.style.outline = ''; }, 2000); +} + +function getCoachFilterOptions() { + const map = new Map(); + allClients.forEach(c => { + if (!c.coachID || map.has(c.coachID)) return; + map.set(c.coachID, c.coachUsername || c.coachID); + }); + return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1])); +} + +function getSupervisorFilterOptions() { + return [...new Set(allClients.map(c => c.supervisorUsername).filter(Boolean))].sort((a, b) => + a.localeCompare(b) + ); +} + +function coachDisplayName(c) { + return c.coachUsername || c.coachID || '—'; +} + +function supervisorDisplayName(c) { + return c.supervisorUsername || '—'; +} + +function getFilteredClients() { + let clients = allClients; + if (filterSupervisor) clients = clients.filter(c => c.supervisorUsername === filterSupervisor); + if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach); + if (filterStatus) clients = clients.filter(c => c.status === filterStatus); + if (filterDateFrom || filterDateTo) { + const fromTs = filterDateFrom ? dateInputToUnixStart(filterDateFrom) : null; + const toTs = filterDateTo ? dateInputToUnixEnd(filterDateTo) : null; + clients = clients.filter(c => { + const ts = c.completedAt; + if (!ts) return false; + if (fromTs !== null && ts < fromTs) return false; + if (toTs !== null && ts > toTs) return false; + return true; + }); + } + if (filterSearch) { + const q = filterSearch.toLowerCase(); + clients = clients.filter(c => { + const hay = [ + c.clientCode, + c.coachUsername, + c.supervisorUsername, + c.status, + ].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q); + }); + } + return clients; +} + +function renderPagination(totalRows) { + const bar = document.getElementById('paginationBar'); + if (!bar) return; + const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + if (page >= totalPages) page = totalPages - 1; + if (page < 0) page = 0; + + if (totalRows <= PAGE_SIZE) { + bar.innerHTML = totalRows + ? `${totalRows} row${totalRows === 1 ? '' : 's'}` + : ''; + return; + } + + const from = page * PAGE_SIZE + 1; + const to = Math.min((page + 1) * PAGE_SIZE, totalRows); + bar.innerHTML = ` + + Rows ${from}–${to} of ${totalRows} (page ${page + 1}/${totalPages}) + + `; + document.getElementById('pagePrev')?.addEventListener('click', () => { + page--; + renderTableRows(); + }); + document.getElementById('pageNext')?.addEventListener('click', () => { + page++; + renderTableRows(); + }); +} + +function dateInputToUnixStart(yyyyMmDd) { + const [y, m, d] = yyyyMmDd.split('-').map(Number); + return Math.floor(new Date(y, m - 1, d).getTime() / 1000); +} + +function dateInputToUnixEnd(yyyyMmDd) { + const [y, m, d] = yyyyMmDd.split('-').map(Number); + return Math.floor(new Date(y, m - 1, d, 23, 59, 59, 999).getTime() / 1000); +} + +function renderTableRows() { + const body = document.getElementById('resultsBody'); + let clients = getFilteredClients(); + + // Sort + if (sortCol) { + clients = [...clients].sort((a, b) => { + let va = getCellValue(a, sortCol); + let vb = getCellValue(b, sortCol); + if (va == null) va = ''; + if (vb == null) vb = ''; + if (typeof va === 'number' && typeof vb === 'number') { + return sortDir === 'asc' ? va - vb : vb - va; + } + const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true }); + return sortDir === 'asc' ? cmp : -cmp; + }); + } + + document.getElementById('resultCount').textContent = `${clients.length} client${clients.length === 1 ? '' : 's'}`; + + renderPagination(clients.length); + + if (!clients.length) { + body.innerHTML = `No results found`; + return; + } + + const totalPages = Math.max(1, Math.ceil(clients.length / PAGE_SIZE)); + if (page >= totalPages) page = totalPages - 1; + clients = clients.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + // Build option text lookup + const optionMap = {}; + questionsDef.forEach(q => { + (q.answerOptions || []).forEach(o => { + optionMap[o.answerOptionID] = o.defaultText; + }); + }); + + body.innerHTML = clients.map(c => { + const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—'; + const statusBadge = c.status ? `${esc(c.status)}` : '—'; + + const questionCells = resultColumns.map(col => { + const ans = c.answers && c.answers[col.questionID]; + const val = resultColumnCellValue(col, ans, optionMap); + if (val == null || val === '') return '—'; + return `${esc(String(val))}`; + }).join(''); + + return ` + ${esc(c.clientCode)} + ${esc(coachDisplayName(c))} + ${esc(supervisorDisplayName(c))} + ${statusBadge} + ${c.sumPoints !== null ? c.sumPoints : '—'} + ${completedDate} + ${questionCells} + `; + }).join(''); +} + +function getCellValue(client, key) { + if (key === 'clientCode') return client.clientCode; + if (key === 'coachUsername') return coachDisplayName(client); + if (key === 'supervisorUsername') return supervisorDisplayName(client); + if (key === 'status') return client.status; + if (key === 'sumPoints') return client.sumPoints; + if (key === 'completedAt') return client.completedAt; + const col = resultColumns.find(c => c.key === key); + if (col) { + const ans = client.answers && client.answers[col.questionID]; + const optionMap = {}; + questionsDef.forEach(q => { + (q.answerOptions || []).forEach(o => { + optionMap[o.answerOptionID] = o.defaultText; + }); + }); + return resultColumnCellValue(col, ans, optionMap); + } + return null; +} + +function exportCSV() { + const clients = getFilteredClients(); + const optionMap = {}; + questionsDef.forEach(q => { + (q.answerOptions || []).forEach(o => { + optionMap[o.answerOptionID] = o.defaultText; + }); + }); + + const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt', + ...resultColumns.map(col => col.label)]; + + const rows = clients.map(c => { + const base = [ + c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '', + c.completedAt ? new Date(c.completedAt * 1000).toISOString() : '' + ]; + const answerCols = resultColumns.map(col => { + const ans = c.answers && c.answers[col.questionID]; + const val = resultColumnCellValue(col, ans, optionMap); + return val != null ? val : ''; + }); + return [...base, ...answerCols]; + }); + + let csv = '\uFEFF'; // BOM + csv += headers.map(csvEsc).join(',') + '\n'; + rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; }); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${questionnaireMeta.name}_v${questionnaireMeta.version}_results.csv`; + a.click(); + URL.revokeObjectURL(url); + showToast('CSV exported', 'success'); +} + +function csvEsc(val) { + const s = String(val ?? ''); + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js new file mode 100644 index 0000000..1103a5e --- /dev/null +++ b/website/js/pages/translations.js @@ -0,0 +1,707 @@ +import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch, apiUrl } from '../api.js'; +import { canEdit, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; +import { + SOURCE_LANG, + normalizeEntry, + translationFor, + targetLanguages, + sourceLanguageLabel, + translationListHeaderHTML, + buildTranslationGroups, + renderCollapsibleGroupedTranslationsHTML, + flattenAllQuestionnaires, + countMissing, + sectionEntries, + escHtml as esc, +} from '../translations-helpers.js'; + +const STORAGE_LANG = 'qdb_trans_lang'; +const STORAGE_SCOPE = 'qdb_trans_scope'; +const STORAGE_SHOW = 'qdb_trans_show_mode'; + +let transData = null; +let allEntries = []; +let selectedLang = ''; +let selectedScope = ''; +/** @type {'missing' | 'all' | 'complete'} */ +let showMode = 'missing'; +let saveTimers = {}; +let savesInFlight = 0; + +export async function translationsPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + ${pageHeaderHTML('Translations', '')} +
        + `; + + if (canEdit()) { + document.getElementById('transHeaderActions').innerHTML = ` + + + + `; + bindTranslationBundleActions(); + } + + try { + renderShell(); + await loadTranslations(); + } catch (e) { + document.getElementById('transPageRoot').innerHTML = + `

        ${esc(e.message)}

        `; + } +} + +function renderShell() { + document.getElementById('transPageRoot').innerHTML = ` +
        +
        + +
        + +
        + More languages +
        Loading…
        +
        + +
        + + + + +
        + +
        +
        + `; +} + +function pickDefaultTargetLang(languages) { + const targets = targetLanguages(languages); + if (!targets.length) return ''; + const stored = localStorage.getItem(STORAGE_LANG); + if (stored && targets.some(l => l.languageCode === stored)) { + return stored; + } + if (selectedLang && targets.some(l => l.languageCode === selectedLang)) { + return selectedLang; + } + return targets[0].languageCode; +} + +function pickDefaultShowMode() { + const stored = localStorage.getItem(STORAGE_SHOW); + if (stored === 'missing' || stored === 'all' || stored === 'complete') { + return stored; + } + return 'missing'; +} + +function pickDefaultScope(sections, entries, lang) { + const stored = localStorage.getItem(STORAGE_SCOPE); + if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) { + return stored; + } + for (const sec of sections) { + const slice = sectionEntries(entries, sec); + if (countMissing(slice, lang) > 0) { + return sec.questionnaireID; + } + } + return sections[0]?.questionnaireID || '__app__'; +} + +function scopeLabel(sec, entries, lang) { + const slice = sectionEntries(entries, sec); + const missing = countMissing(slice, lang); + const suffix = missing ? ` (${missing} missing)` : ' (complete)'; + return `${sec.name}${suffix}`; +} + +function scopedSections() { + const sections = transData?.sections || []; + if (!selectedScope || selectedScope === '__all__') { + return sections; + } + return sections.filter(s => s.questionnaireID === selectedScope); +} + +function entriesInScope() { + const sections = scopedSections(); + const out = []; + for (const sec of sections) { + out.push(...sectionEntries(allEntries, sec)); + } + return out; +} + +function setSaveStatus(state) { + const el = document.getElementById('transSaveStatus'); + if (!el) return; + el.dataset.state = state; + if (state === 'saving') { + el.textContent = 'Saving…'; + } else if (state === 'error') { + el.textContent = 'Save failed'; + } else { + el.textContent = 'All changes saved'; + } +} + +async function ensureGermanLanguage() { + try { + await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' }); + } catch (_) { /* already exists */ } +} + +async function loadTranslations() { + const listArea = document.getElementById('transListArea'); + const langSelect = document.getElementById('transLangSelect'); + const filter = document.getElementById('transFilter'); + const typeFilter = document.getElementById('transTypeFilter'); + + if (listArea) listArea.innerHTML = '
        '; + saveTimers = {}; + + try { + await ensureGermanLanguage(); + const data = await apiGet('translations.php?all=1'); + transData = data; + const languages = transData.languages || []; + const questionnaires = transData.questionnaires || []; + + const flat = flattenAllQuestionnaires(questionnaires, data.appStrings || []); + allEntries = flat.allEntries; + transData.sections = flat.sections; + + selectedLang = pickDefaultTargetLang(languages); + showMode = pickDefaultShowMode(); + selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang); + + if (langSelect) { + const targets = targetLanguages(languages); + langSelect.disabled = !targets.length; + langSelect.innerHTML = targets.length + ? targets.map(l => { + const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode; + return ``; + }).join('') + : ''; + langSelect.onchange = () => { + selectedLang = langSelect.value; + localStorage.setItem(STORAGE_LANG, selectedLang); + populateScopeSelect(); + renderEntryList(); + }; + } + + const scopeSelect = document.getElementById('transScopeSelect'); + const showSelect = document.getElementById('transShowMode'); + if (scopeSelect) { + scopeSelect.disabled = false; + populateScopeSelect(); + scopeSelect.onchange = () => { + selectedScope = scopeSelect.value; + localStorage.setItem(STORAGE_SCOPE, selectedScope); + renderEntryList(); + }; + } + if (showSelect) { + showSelect.disabled = false; + showSelect.value = showMode; + showSelect.onchange = () => { + showMode = showSelect.value; + localStorage.setItem(STORAGE_SHOW, showMode); + applyFilters(); + }; + } + + if (filter) filter.disabled = false; + if (typeFilter) typeFilter.disabled = false; + + renderLanguagePanel(languages); + renderEntryList(); + bindFilters(); + setSaveStatus('saved'); + } catch (e) { + if (listArea) listArea.innerHTML = `

        ${esc(e.message)}

        `; + showToast(e.message, 'error'); + } +} + +function renderLanguagePanel(languages) { + const panel = document.getElementById('transLangPanel'); + if (!panel) return; + + const others = targetLanguages(languages); + + panel.innerHTML = ` +

        + German source text is edited in the questionnaire editor. Add other languages here. +

        +
        + + DE + German (source) + + ${others.map(l => ` + + ${esc(l.languageCode.toUpperCase())} + ${l.name ? `${esc(l.name)}` : ''} + + + `).join('')} +
        +
        + + + +
        + `; + + document.getElementById('addLangBtn')?.addEventListener('click', async () => { + const code = document.getElementById('newLangCode').value.trim().toLowerCase(); + const name = document.getElementById('newLangName').value.trim(); + if (!code) { showToast('Language code is required', 'error'); return; } + if (code === SOURCE_LANG) { + showToast('German (de) is always available as the source language', 'error'); + return; + } + if (languages.some(l => l.languageCode === code)) { + showToast('Language already exists', 'error'); + return; + } + try { + await apiPut('translations.php', { type: 'language', languageCode: code, name }); + showToast(`Language "${code}" added`, 'success'); + selectedLang = code; + await loadTranslations(); + } catch (e) { + showToast(e.message, 'error'); + } + }); + + panel.querySelectorAll('.lang-chip-remove').forEach(btn => { + btn.addEventListener('click', async () => { + const lc = btn.dataset.lc; + if (!(await confirmAction({ + title: 'Remove language', + message: `Remove "${lc}" and all its translations?`, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; + try { + await apiDelete('translations.php', { type: 'language', languageCode: lc }); + showToast(`Language "${lc}" removed`, 'success'); + if (selectedLang === lc) selectedLang = ''; + await loadTranslations(); + } catch (e) { + showToast(e.message, 'error'); + } + }); + }); +} + +function populateScopeSelect() { + const scopeSelect = document.getElementById('transScopeSelect'); + if (!scopeSelect || !transData) return; + + const sections = transData.sections || []; + const options = sections.map(sec => + `` + ); + options.push(``); + + scopeSelect.innerHTML = options.join(''); + if (!selectedScope || (selectedScope !== '__all__' && !sections.some(s => s.questionnaireID === selectedScope))) { + selectedScope = pickDefaultScope(sections, allEntries, selectedLang); + scopeSelect.value = selectedScope; + localStorage.setItem(STORAGE_SCOPE, selectedScope); + } +} + +function renderEntryList() { + const listArea = document.getElementById('transListArea'); + if (!listArea || !transData) return; + + const languages = transData.languages || []; + const targets = targetLanguages(languages); + const sections = scopedSections(); + + if (!targets.length) { + listArea.innerHTML = ` +

        Add at least one language under More languages to translate into.

        `; + return; + } + + if (!selectedLang) { + listArea.innerHTML = `

        Choose a language above.

        `; + return; + } + + if (!transData.sections?.length) { + listArea.innerHTML = ` +

        No translatable content yet. Add questionnaires and questions in the editor first.

        `; + return; + } + + const sourceLabel = sourceLanguageLabel(languages); + const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase(); + const scopeEntries = entriesInScope(); + const missingCount = countMissing(scopeEntries, selectedLang); + const pct = scopeEntries.length + ? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100) + : 0; + + const headerOnce = translationListHeaderHTML(langLabel, sourceLabel); + const useQnDetails = selectedScope === '__all__'; + + const sectionsHtml = sections.map(sec => { + const entries = sectionEntries(allEntries, sec); + const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx); + const secMissing = countMissing(entries, selectedLang); + const inner = renderCollapsibleGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, { + openGroupsWithMissing: selectedScope !== '__all__', + }); + + if (useQnDetails) { + const open = secMissing > 0 && sections.filter(s => + countMissing(sectionEntries(allEntries, s), selectedLang) > 0 + )[0]?.questionnaireID === sec.questionnaireID; + const summary = secMissing + ? `${sec.name} · ${secMissing} missing` + : `${sec.name} · complete`; + return ` +
        + ${esc(summary)} + ${inner} +
        `; + } + + return `
        ${inner}
        `; + }).join(''); + + listArea.innerHTML = ` +
        +
        + ${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope + + + ${pct}% in ${esc(selectedLang.toUpperCase())} + +
        +
        + All changes saved + +
        +
        +
        + ${headerOnce} +
        ${sectionsHtml}
        +
        + `; + + document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing); + + bindEntryEditing(allEntries); + applyFilters(); +} + +function openAncestors(el) { + let node = el; + while (node) { + if (node.tagName === 'DETAILS') node.open = true; + node = node.parentElement; + } +} + +function jumpToNextMissing() { + const rows = [...document.querySelectorAll('#transGroupedRoot .trans-list-item')] + .filter(r => !r.classList.contains('trans-row-hidden') && r.dataset.missing === '1'); + if (!rows.length) { + showToast(showMode === 'missing' + ? 'All translations in this scope are complete' + : 'No missing translations in view', 'info'); + return; + } + + const activeRow = document.activeElement?.closest?.('.trans-list-item'); + let start = 0; + if (activeRow) { + const idx = rows.indexOf(activeRow); + if (idx >= 0) start = idx + 1; + } + const target = rows[start] || rows[0]; + openAncestors(target); + const inp = target.querySelector('[data-field="translation"]'); + inp?.focus({ preventScroll: false }); + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} + +function bindEntryEditing(entries) { + document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => { + inp.addEventListener('input', () => { + const idx = parseInt(inp.dataset.idx, 10); + const entry = entries[idx]; + if (!entry) return; + + const isGerman = inp.dataset.field === 'german'; + if (isGerman) { + entry.germanText = inp.value; + if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {}; + entry.translations[SOURCE_LANG] = inp.value; + } else { + if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {}; + entry.translations[selectedLang] = inp.value; + } + + if (!isGerman) { + const missing = !inp.value.trim(); + inp.classList.toggle('trans-input-missing', missing); + const row = inp.closest('.trans-list-item'); + if (row) { + row.classList.toggle('trans-list-item-missing', missing); + row.dataset.missing = missing ? '1' : '0'; + } + } + + const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`; + clearTimeout(saveTimers[timerKey]); + saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500); + setSaveStatus('saving'); + }); + }); +} + +async function saveTranslation(entry, inp, isGerman = false) { + savesInFlight++; + setSaveStatus('saving'); + try { + await apiPut('translations.php', { + type: entry.type, + id: entry.entityId, + languageCode: isGerman ? SOURCE_LANG : selectedLang, + text: inp.value, + }); + inp.classList.add('save-flash'); + setTimeout(() => inp.classList.remove('save-flash'), 400); + updateProgress(); + } catch (e) { + setSaveStatus('error'); + showToast(e.message, 'error'); + } finally { + savesInFlight = Math.max(0, savesInFlight - 1); + if (savesInFlight === 0) { + setSaveStatus('saved'); + populateScopeSelect(); + } + } +} + +function updateProgress() { + const scopeEntries = entriesInScope(); + const missing = countMissing(scopeEntries, selectedLang); + const pct = scopeEntries.length + ? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100) + : 0; + const fill = document.querySelector('.trans-progress-fill'); + if (fill) fill.style.width = `${pct}%`; + const label = document.querySelector('.trans-progress-label'); + if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`; + if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) { + updateVisibleCountSummary(); + } +} + +function updateVisibleCountSummary(visible = null, visibleMissing = null) { + const countEl = document.getElementById('transVisibleCount'); + if (!countEl) return; + const scopeEntries = entriesInScope(); + const text = document.getElementById('transFilter')?.value || ''; + const type = document.getElementById('transTypeFilter')?.value || ''; + if (text || type || showMode !== 'missing') { + if (visible != null) { + countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`; + } + return; + } + const missing = countMissing(scopeEntries, selectedLang); + countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`; +} + +let filtersBound = false; + +function bindFilters() { + if (filtersBound) { + applyFilters(); + return; + } + filtersBound = true; + + document.getElementById('transFilter')?.addEventListener('input', applyFilters); + document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters); +} + +function applyFilters() { + const text = (document.getElementById('transFilter')?.value || '').toLowerCase(); + const type = document.getElementById('transTypeFilter')?.value || ''; + const items = document.querySelectorAll('#transGroupedRoot .trans-list-item'); + let visible = 0; + let visibleMissing = 0; + + items.forEach(row => { + const key = row.dataset.key?.toLowerCase() || ''; + const label = row.dataset.label?.toLowerCase() + || row.querySelector('.trans-key-text')?.textContent?.toLowerCase() || ''; + const rowType = row.dataset.type || ''; + const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase() + || row.querySelector('[data-field="german"]')?.value?.toLowerCase() || ''; + const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || ''; + const isMissing = row.dataset.missing === '1'; + let show = (!type || rowType === type) && + (!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text)); + if (showMode === 'missing' && !isMissing) show = false; + if (showMode === 'complete' && isMissing) show = false; + row.classList.toggle('trans-row-hidden', !show); + if (show) { + visible++; + if (row.dataset.missing === '1') visibleMissing++; + } + }); + + document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => { + const rows = group.querySelectorAll('.trans-list-item'); + const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden')); + group.classList.toggle('trans-group-hidden', !anyVisible); + }); + + document.querySelectorAll('#transGroupedRoot .trans-scope-block, #transGroupedRoot .trans-qn-section').forEach(block => { + const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); + block.classList.toggle('trans-qn-hidden', !anyVisible); + }); + + const emptyEl = document.getElementById('transEmptyHint'); + if (emptyEl) emptyEl.remove(); + if (visible === 0 && items.length > 0) { + const root = document.getElementById('transGroupedRoot'); + if (root) { + const hint = document.createElement('p'); + hint.id = 'transEmptyHint'; + hint.className = 'text-muted trans-hint trans-empty-hint'; + hint.textContent = showMode === 'missing' + ? 'All translations in this scope are complete for the selected language.' + : 'No rows match the current filters.'; + root.insertAdjacentElement('afterend', hint); + } + } + + updateVisibleCountSummary(visible, visibleMissing); +} + +function bindTranslationBundleActions() { + const fileInput = document.getElementById('importTransBundleFile'); + const importBtn = document.getElementById('importTransBundleBtn'); + const exportBtn = document.getElementById('exportTransBundleBtn'); + if (!fileInput || !importBtn || !exportBtn) return; + + exportBtn.addEventListener('click', async () => { + exportBtn.disabled = true; + const prev = exportBtn.textContent; + exportBtn.textContent = 'Preparing…'; + try { + const res = await apiDownloadFetch(apiUrl('translations?exportBundle=1')); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || err.error || 'Export failed'); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const stamp = new Date().toISOString().slice(0, 10); + a.download = `translations_bundle_${stamp}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast('Translations bundle downloaded', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + exportBtn.disabled = false; + exportBtn.textContent = prev; + } + }); + + importBtn.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + fileInput.value = ''; + if (!file) return; + + let bundle; + try { + bundle = JSON.parse(await file.text()); + } catch { + showToast('Invalid JSON file', 'error'); + return; + } + + if (!bundle?.entries || !Array.isArray(bundle.entries)) { + showToast('Not a translations bundle (missing entries array)', 'error'); + return; + } + + const count = bundle.entries.length; + if (!(await confirmAction({ + title: 'Import translations', + message: `Import translations for ${count} entries from this file?`, + confirmLabel: 'Import', + }))) return; + + importBtn.disabled = true; + const prev = importBtn.textContent; + importBtn.textContent = 'Importing…'; + try { + const result = await apiPost('translations.php', { + action: 'importBundle', + bundle, + }); + const imported = result.imported ?? 0; + const skipped = result.skipped ?? 0; + let msg = `Imported ${imported} translation(s)`; + if (skipped) msg += ` (${skipped} skipped)`; + showToast(msg, 'success'); + await loadTranslations(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + importBtn.disabled = false; + importBtn.textContent = prev; + } + }); +} diff --git a/website/js/pages/users.js b/website/js/pages/users.js new file mode 100644 index 0000000..9f8a537 --- /dev/null +++ b/website/js/pages/users.js @@ -0,0 +1,562 @@ +import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; +import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; +import { openAdminResetPasswordModal } from '../password-modal.js'; + +// Roles an admin can create; supervisors can only create coaches +const CREATEABLE_ROLES = { + admin: ['admin', 'supervisor', 'coach'], + supervisor: ['coach'], +}; + +let usersList = []; +let supervisorsList = []; +let callerRole = ''; +/** @type {Record} */ +let filterSearchByRole = { admin: '', supervisor: '', coach: '' }; + +function roleGroupsForCaller() { + return callerRole === 'supervisor' + ? [{ label: 'Counselor', key: 'coach' }] + : [ + { label: 'Admins', key: 'admin' }, + { label: 'Supervisors', key: 'supervisor' }, + { label: 'Counselor', key: 'coach' }, + ]; +} + +function searchPlaceholderForRole(roleKey) { + return roleKey === 'coach' + ? 'Search username or supervisor…' + : 'Search username or location…'; +} + +function usersByRoleMap() { + const byRole = { admin: [], supervisor: [], coach: [] }; + usersList.forEach(u => { + if (byRole[u.role]) byRole[u.role].push(u); + else (byRole.other = byRole.other || []).push(u); + }); + return byRole; +} + +export async function usersPage() { + callerRole = getRole(); + const app = document.getElementById('app'); + + app.innerHTML = ` + ${pageHeaderHTML('Users', ` + + + `)} + +
        + `; + + document.getElementById('showCreateFormBtn').addEventListener('click', () => { + const wrapper = document.getElementById('createUserWrapper'); + if (wrapper.style.display === 'none') { + showCreateForm(); + } else { + hideCreateForm(); + } + }); + document.getElementById('downloadUsersBtn').addEventListener('click', downloadUsersCSV); + + await loadUsers(); +} + +document.addEventListener('qdb:password-reset', (e) => { + const { userID, mustChangePassword } = e.detail || {}; + const u = usersList.find(x => x.userID === userID); + if (u) { + u.mustChangePassword = mustChangePassword; + renderUsers(); + } +}); + +async function loadUsers() { + try { + const data = await apiGet('users.php'); + usersList = data.users || []; + supervisorsList = data.supervisors || []; + callerRole = data.callerRole || callerRole; + renderUsers(); + const dlBtn = document.getElementById('downloadUsersBtn'); + if (dlBtn) dlBtn.disabled = !usersList.length; + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('usersContent').innerHTML = + `

        ${esc(e.message)}

        `; + } +} + +// ── User table ──────────────────────────────────────────────────────────── + +function renderUsers() { + const container = document.getElementById('usersContent'); + const myUsername = getUser(); + + if (!usersList.length) { + container.innerHTML = ` +
        +
        +

        No users found

        +

        ${callerRole === 'supervisor' ? 'You have no counselors yet.' : 'Create the first user above.'}

        +
        +
        `; + return; + } + + const byRole = usersByRoleMap(); + const groups = roleGroupsForCaller(); + + container.innerHTML = groups.map(group => { + const allInRole = byRole[group.key] || []; + if (!allInRole.length) return ''; + return ` +
        +

        + ${group.label} (0) +

        +
        + +
        +
        + + + + + ${group.key !== 'coach' ? '' : ''} + + + + + + +
        UsernameLocationSupervisorMust change passwordCreatedActions
        +
        +
        `; + }).join(''); + + container.addEventListener('input', (e) => { + const input = e.target.closest('.user-role-search'); + if (!input) return; + const roleKey = input.dataset.role; + if (!roleKey) return; + filterSearchByRole[roleKey] = input.value; + updateRoleGroupTable(roleKey, myUsername); + }); + + groups.forEach(group => { + if ((byRole[group.key] || []).length) { + updateRoleGroupTable(group.key, myUsername); + } + }); +} + +function filterUsers(users, roleKey) { + let list = users; + const q = (filterSearchByRole[roleKey] || '').trim().toLowerCase(); + if (q) { + list = list.filter(u => { + const hay = [u.username, u.location, u.supervisorUsername].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q); + }); + } + return list; +} + +function updateRoleGroupTable(roleKey, myUsername) { + const tbody = document.getElementById(`usersBody-${roleKey}`); + const countEl = document.querySelector(`[data-role-count="${roleKey}"]`); + if (!tbody) return; + + const byRole = usersByRoleMap(); + const allInRole = byRole[roleKey] || []; + const users = filterUsers(allInRole, roleKey); + const colSpan = 5; + + if (countEl) { + const q = (filterSearchByRole[roleKey] || '').trim(); + countEl.textContent = q && users.length !== allInRole.length + ? `${users.length} of ${allInRole.length}` + : String(users.length); + } + + if (!users.length) { + tbody.innerHTML = ` + + + ${allInRole.length ? 'No users match' : 'No users'} + + `; + return; + } + + tbody.innerHTML = users.map(u => userRowHTML(u, myUsername)).join(''); + tbody.querySelectorAll('.delete-user-btn').forEach(btn => { + btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name)); + }); + tbody.querySelectorAll('.reset-password-btn').forEach(btn => { + btn.addEventListener('click', () => openAdminResetPasswordModal({ + userID: btn.dataset.id, + username: btn.dataset.name, + role: btn.dataset.role || '', + })); + }); + tbody.querySelectorAll('.revoke-sessions-btn').forEach(btn => { + btn.addEventListener('click', () => revokeUserSessions(btn.dataset.id, btn.dataset.name)); + }); + tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => { + sel.addEventListener('change', () => reassignCoachSupervisor(sel)); + }); +} + +function supervisorSelectHTML(u) { + if (!supervisorsList.length) { + return 'No supervisors'; + } + const cur = u.supervisorID || ''; + const opts = supervisorsList.map(s => { + const label = s.location ? `${s.username} (${s.location})` : s.username; + const selected = s.supervisorID === cur ? ' selected' : ''; + return ``; + }).join(''); + return ``; +} + +function userRowHTML(u, myUsername) { + const isSelf = u.username === myUsername; + let detail; + if (u.role === 'coach') { + detail = callerRole === 'admin' + ? supervisorSelectHTML(u) + : esc(u.supervisorUsername || '—'); + } else { + detail = esc(u.location || '—'); + } + const created = u.createdAt + ? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString() + : '—'; + const pwBadge = u.mustChangePassword == 1 + ? 'Temporary' + : ''; + + const canDelete = !isSelf && ( + callerRole === 'admin' || + (callerRole === 'supervisor' && u.role === 'coach') + ); + const canResetPassword = !isSelf && ( + (callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) || + (callerRole === 'supervisor' && u.role === 'coach') + ); + const canRevokeSessions = !isSelf && ( + callerRole === 'admin' || + (callerRole === 'supervisor' && u.role === 'coach') + ); + + const actions = []; + if (canResetPassword) { + actions.push( + `` + ); + } + if (canRevokeSessions) { + actions.push( + `` + ); + } + if (canDelete) { + actions.push( + `` + ); + } + + return ` + + + ${esc(u.username)} + ${isSelf ? 'You' : ''} + + ${detail} + ${pwBadge} + ${created} + ${actions.join(' ')} + `; +} + +async function reassignCoachSupervisor(selectEl) { + const userID = selectEl.dataset.userId; + const username = selectEl.dataset.username || ''; + const prev = selectEl.dataset.prev || ''; + const supervisorID = selectEl.value; + + if (supervisorID === prev) { + return; + } + + selectEl.disabled = true; + try { + const data = await apiPut('users.php', { userID, supervisorID }); + const u = usersList.find(x => x.userID === userID); + if (u) { + u.supervisorID = data.supervisorID ?? supervisorID; + u.supervisorUsername = data.supervisorUsername ?? ''; + } + selectEl.dataset.prev = supervisorID; + showToast(`Counselor "${username}" assigned to ${data.supervisorUsername || 'supervisor'}`, 'success'); + } catch (e) { + selectEl.value = prev; + showToast(e.message, 'error'); + } finally { + selectEl.disabled = false; + } +} + +async function revokeUserSessions(userID, username) { + if (!(await confirmAction({ + title: 'Sign out everywhere', + message: `Sign out "${username}" on all devices?\n\nThey must log in again on the website and mobile app.`, + confirmLabel: 'Sign out', + variant: 'danger', + }))) { + return; + } + try { + const data = await apiPut('users.php', { action: 'revokeSessions', userID }); + if (!data.success) throw new Error(data.error || 'Failed'); + const n = data.revokedSessions ?? 0; + showToast(`Signed out "${username}" (${n} session${n === 1 ? '' : 's'} revoked)`, 'success'); + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function deleteUser(userID, username) { + if (!(await confirmAction({ + title: 'Delete user', + message: `Delete user "${username}"? This cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + try { + const data = await apiDelete('users.php', { userID }); + if (data.success) { + usersList = usersList.filter(u => u.userID !== userID); + showToast(`User "${username}" deleted`, 'success'); + renderUsers(); + } + } catch (e) { + showToast(e.message, 'error'); + } +} + +// ── Create user form ────────────────────────────────────────────────────── + +function showCreateForm() { + const wrapper = document.getElementById('createUserWrapper'); + const allowedRoles = CREATEABLE_ROLES[callerRole] || ['coach']; + const isSupervisor = callerRole === 'supervisor'; + + wrapper.style.display = ''; + wrapper.innerHTML = ` +
        +

        Create New User

        +
        +
        + + +
        +
        + + +
        +
        + + ${isSupervisor + ? `` + : `` + } +
        +
        + +
        + + +
        + +
        + + ${isSupervisor + ? `` + : `` + } +
        + + + +
        + + +
        + +
        + `; + + document.getElementById('cu_username').focus(); + + // Show/hide location vs supervisor field based on role selection (admin only) + if (!isSupervisor) { + document.getElementById('cu_role').addEventListener('change', (e) => { + const r = e.target.value; + document.getElementById('cu_location_row').style.display = r !== 'coach' ? '' : 'none'; + document.getElementById('cu_supervisor_row').style.display = r === 'coach' ? '' : 'none'; + }); + // Set initial state based on default selection + const initRole = document.getElementById('cu_role').value; + document.getElementById('cu_location_row').style.display = initRole !== 'coach' ? '' : 'none'; + document.getElementById('cu_supervisor_row').style.display = initRole === 'coach' ? '' : 'none'; + } + + document.getElementById('cu_submit').addEventListener('click', submitCreateUser); + document.getElementById('cu_cancel').addEventListener('click', hideCreateForm); + + // Enter key on last visible field submits + wrapper.querySelectorAll('input').forEach(inp => { + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitCreateUser(); + if (e.key === 'Escape') hideCreateForm(); + }); + }); +} + +function hideCreateForm() { + const wrapper = document.getElementById('createUserWrapper'); + if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } +} + +async function submitCreateUser() { + const errEl = document.getElementById('cu_error'); + errEl.style.display = 'none'; + + const isSupervisor = callerRole === 'supervisor'; + const username = document.getElementById('cu_username').value.trim(); + const password = document.getElementById('cu_password').value; + const role = isSupervisor ? 'coach' : (document.getElementById('cu_role').value || 'coach'); + const mustChange = 1; + + let location = ''; + let supervisorID = ''; + if (!isSupervisor) { + if (role !== 'coach') { + location = document.getElementById('cu_location').value.trim(); + } else { + supervisorID = document.getElementById('cu_supervisor_select').value; + } + } + + if (!username) { showError(errEl, 'Username is required'); return; } + if (password.length < 6) { showError(errEl, 'Password must be at least 6 characters'); return; } + if (role === 'coach' && !isSupervisor && !supervisorID) { + showError(errEl, 'Please select a supervisor for this counselor'); + return; + } + + const btn = document.getElementById('cu_submit'); + btn.disabled = true; + btn.textContent = 'Creating...'; + + try { + const data = await apiPost('users.php', { + username, password, role, location, supervisorID, mustChangePassword: mustChange, + }); + if (!data.success) throw new Error(data.error || 'Failed to create user'); + + // Add to local list and re-render + usersList.push({ + userID: data.userID, + username: data.username, + role: data.role, + entityID: data.entityID, + location: data.location || '', + supervisorID: data.supervisorID || null, + supervisorUsername: data.supervisorUsername || null, + mustChangePassword: data.mustChangePassword, + createdAt: data.createdAt, + }); + showToast(`User "${data.username}" created`, 'success'); + hideCreateForm(); + renderUsers(); + } catch (e) { + showError(errEl, e.message); + btn.disabled = false; + btn.textContent = 'Create User'; + } +} + +function showError(el, msg) { + el.textContent = msg; + el.style.display = ''; +} + +function downloadUsersCSV() { + if (!usersList.length) { + showToast('No users to export', 'error'); + return; + } + + const headers = ['username', 'role', 'location', 'supervisor', 'mustChangePassword', 'createdAt', 'userID']; + const rows = usersList.map(u => [ + u.username, + u.role === 'coach' ? 'Counselor' : u.role, + u.role === 'coach' ? '' : (u.location || ''), + u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '', + u.mustChangePassword == 1 ? 'yes' : 'no', + u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '', + u.userID, + ]); + + let csv = '\uFEFF'; + csv += headers.map(csvEsc).join(',') + '\n'; + rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; }); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + showToast('Users exported', 'success'); +} + +function csvEsc(val) { + const s = String(val ?? ''); + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} diff --git a/website/js/password-modal.js b/website/js/password-modal.js new file mode 100644 index 0000000..8fb35a3 --- /dev/null +++ b/website/js/password-modal.js @@ -0,0 +1,208 @@ +import { apiPatch, apiPost, invalidateSessionCheck } from './api.js'; +import { getRole, getUser, showToast, formatRoleLabel } from './app.js'; + +/** @typedef {'admin_reset' | 'self'} PasswordModalMode */ + +/** @type {{ mode: PasswordModalMode, userID?: string, username?: string, role?: string } | null} */ +let modalContext = null; + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function roleLabel(role) { + return formatRoleLabel(role); +} + +function signInHintForRole(role) { + if (role === 'coach') { + return 'Counselors sign in through the mobile app and will be prompted to choose a new password there.'; + } + return 'Supervisors sign in through this website and will be prompted to choose a new password at login.'; +} + +function ensurePasswordModal() { + let modal = document.getElementById('passwordModal'); + if (modal) return modal; + + modal = document.createElement('div'); + modal.id = 'passwordModal'; + modal.className = 'modal-overlay'; + modal.hidden = true; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'passwordModalTitle'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closePasswordModal(); + }); + modal.querySelector('[data-password-cancel]').addEventListener('click', closePasswordModal); + modal.querySelector('[data-password-submit]').addEventListener('click', submitPasswordModal); + modal.querySelector('#pw_new_password_confirm').addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitPasswordModal(); + }); + + document.addEventListener('keydown', (e) => { + const el = document.getElementById('passwordModal'); + if (!el || el.hidden) return; + if (e.key === 'Escape') closePasswordModal(); + }); + + return modal; +} + +/** + * @param {{ mode: PasswordModalMode, userID?: string, username?: string, role?: string }} options + */ +function openPasswordModal(options) { + modalContext = options; + const modal = ensurePasswordModal(); + const isSelf = options.mode === 'self'; + const username = options.username || getUser(); + const role = options.role || getRole(); + + modal.querySelector('#passwordModalTitle').textContent = isSelf ? 'Change your password' : 'Reset password'; + modal.querySelector('#passwordModalSubtitle').innerHTML = isSelf + ? `Choose a new password for ${esc(username)} (${esc(roleLabel(role))}).` + : `Set a new password for ${esc(username)} (${esc(roleLabel(role))}).`; + modal.querySelector('#passwordModalHint').textContent = isSelf + ? 'You will stay signed in after saving. Use this password on your next login.' + : signInHintForRole(role); + + modal.querySelector('#passwordModalTempHint').style.display = isSelf ? 'none' : ''; + modal.querySelector('#passwordModalOldGroup').style.display = isSelf ? '' : 'none'; + + modal.querySelector('#pw_old_password').value = ''; + modal.querySelector('#pw_new_password').value = ''; + modal.querySelector('#pw_new_password_confirm').value = ''; + const errEl = modal.querySelector('#passwordModalError'); + errEl.style.display = 'none'; + errEl.textContent = ''; + + modal.querySelector('[data-password-submit]').textContent = isSelf ? 'Change password' : 'Set password'; + + modal.hidden = false; + document.body.classList.add('modal-open'); + (isSelf ? modal.querySelector('#pw_old_password') : modal.querySelector('#pw_new_password')).focus(); +} + +export function closePasswordModal() { + const modal = document.getElementById('passwordModal'); + if (modal) modal.hidden = true; + document.body.classList.remove('modal-open'); + modalContext = null; +} + +export function openAdminResetPasswordModal({ userID, username, role }) { + openPasswordModal({ mode: 'admin_reset', userID, username, role }); +} + +export function openChangeOwnPasswordModal() { + openPasswordModal({ mode: 'self' }); +} + +function showModalError(el, msg) { + el.textContent = msg; + el.style.display = ''; +} + +async function submitPasswordModal() { + if (!modalContext) return; + const modal = document.getElementById('passwordModal'); + const errEl = modal.querySelector('#passwordModalError'); + errEl.style.display = 'none'; + + const newPassword = modal.querySelector('#pw_new_password').value; + const confirm = modal.querySelector('#pw_new_password_confirm').value; + + if (newPassword.length < 6) { + showModalError(errEl, 'Password must be at least 6 characters'); + return; + } + if (newPassword !== confirm) { + showModalError(errEl, 'Passwords do not match'); + return; + } + + const submitBtn = modal.querySelector('[data-password-submit]'); + submitBtn.disabled = true; + const prevLabel = submitBtn.textContent; + submitBtn.textContent = 'Saving…'; + + try { + if (modalContext.mode === 'self') { + const oldPassword = modal.querySelector('#pw_old_password').value; + if (!oldPassword) { + showModalError(errEl, 'Current password is required'); + return; + } + const data = await apiPost('auth/change-password', { + username: getUser(), + old_password: oldPassword, + new_password: newPassword, + }); + if (!data.success) throw new Error(data.error || 'Failed to change password'); + if (data.token) localStorage.setItem('qdb_token', data.token); + if (data.user) localStorage.setItem('qdb_user', data.user); + if (data.role) localStorage.setItem('qdb_role', data.role); + invalidateSessionCheck(); + closePasswordModal(); + showToast('Your password was changed', 'success'); + return; + } + + const { userID, username } = modalContext; + + const data = await apiPatch('users.php', { + userID, + password: newPassword, + mustChangePassword: 1, + }); + if (!data.success) throw new Error(data.error || 'Failed to reset password'); + + closePasswordModal(); + showToast( + `Temporary password set for "${username}" — they must change it on next sign-in`, + 'success' + ); + + document.dispatchEvent(new CustomEvent('qdb:password-reset', { + detail: { userID, mustChangePassword: data.mustChangePassword ?? 1 }, + })); + } catch (e) { + showModalError(errEl, e.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = prevLabel; + } +} diff --git a/website/js/questionnaire-preview.js b/website/js/questionnaire-preview.js new file mode 100644 index 0000000..5ea2e06 --- /dev/null +++ b/website/js/questionnaire-preview.js @@ -0,0 +1,555 @@ +/** + * All-in-one interactive preview for questionnaire editing. + * All visible questions on one scrollable page; branching updates visibility live. + * Answers stay in memory only — nothing is saved to the server. + */ + +const GLASS_LABELS = [ + { key: 'never_glass', label: 'Never' }, + { key: 'little_glass', label: 'A little' }, + { key: 'moderate_glass', label: 'Moderate' }, + { key: 'much_glass', label: 'Much' }, + { key: 'extreme_glass', label: 'Extreme' }, +]; + +/** Sample codes shown in preview (read-only), mimicking the app after load/login. */ +const PREVIEW_CLIENT_CODE = 'CLIENT-042'; +const PREVIEW_COACH_CODE = 'coach.demo'; + +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function parseConfig(q) { + try { return JSON.parse(q.configJson || '{}'); } catch { return {}; } +} + +function localId(q) { + return q.localId || (q.questionID || '').split('__').pop() || ''; +} + +function questionKey(q) { + const cfg = parseConfig(q); + return (q.questionKey || cfg.questionKey || '').trim(); +} + +function optionKeyOf(o) { + if (o.optionKey) return o.optionKey; + const dt = (o.defaultText || '').trim(); + return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(dt) ? dt : dt; +} + +function optionLabel(o) { + return (o.labelGerman || '').trim() || o.defaultText || o.optionKey || '?'; +} + +function glassScaleOptions(q) { + const opts = q.answerOptions || []; + if (opts.length) { + return opts.map(o => ({ key: optionKeyOf(o), label: optionLabel(o) })); + } + return GLASS_LABELS; +} + +function valueSpinnerValues(config) { + const range = config.range || { min: 0, max: 10 }; + const min = Number(range.min ?? 0); + const max = Number(range.max ?? 10); + const lo = Math.min(min, max); + const hi = Math.max(min, max); + const values = []; + for (let v = lo; v <= hi; v++) values.push(v); + return values; +} + +function hasBranchingOptions(q) { + const layout = q.type || ''; + const config = parseConfig(q); + if (layout === 'radio_question') { + return (q.answerOptions || []).some(o => o.nextQuestionId?.trim()); + } + if (layout === 'value_spinner') { + const opts = config.valueOptions || config.options || []; + return opts.some(o => o.nextQuestionId?.trim()) || Boolean(config.nextQuestionId?.trim()); + } + if (layout === 'string_spinner') { + return Boolean(config.nextQuestionId?.trim() || config.otherNextQuestionId?.trim()); + } + if (layout === 'multi_check_box_question') { + return Boolean(config.nextQuestionId?.trim() || config.otherNextQuestionId?.trim()); + } + const withQuestionNext = new Set([ + 'free_text', 'date_spinner', 'slider_question', 'glass_scale_question', + 'client_coach_code_question', + ]); + return withQuestionNext.has(layout) && Boolean(config.nextQuestionId?.trim()); +} + +function resolveNextLocalId(q, answer) { + const layout = q.type || ''; + const config = parseConfig(q); + + if (layout === 'radio_question') { + const key = typeof answer === 'string' ? answer : ''; + const opt = (q.answerOptions || []).find(o => optionKeyOf(o) === key); + return opt?.nextQuestionId?.trim() || null; + } + if (layout === 'value_spinner') { + const num = parseInt(String(answer), 10); + const branch = (config.valueOptions || config.options || []) + .find(o => Number(o.value) === num); + if (branch?.nextQuestionId?.trim()) return branch.nextQuestionId.trim(); + return config.nextQuestionId?.trim() || null; + } + if (layout === 'string_spinner') { + const val = String(answer ?? ''); + const otherKey = config.otherOptionKey?.trim(); + const otherNext = config.otherNextQuestionId?.trim(); + if (otherKey && otherNext && val === otherKey) return otherNext; + return config.nextQuestionId?.trim() || null; + } + if (layout === 'multi_check_box_question') { + const keys = Array.isArray(answer) ? answer : []; + const otherKey = config.otherOptionKey?.trim(); + const otherNext = config.otherNextQuestionId?.trim(); + if (otherKey && otherNext && keys.includes(otherKey)) return otherNext; + return config.nextQuestionId?.trim() || null; + } + return config.nextQuestionId?.trim() || null; +} + +function readAnswerFromBody(body, q) { + if (!body) return null; + const layout = q.type || ''; + const config = parseConfig(q); + + switch (layout) { + case 'radio_question': { + const checked = body.querySelector('input[type=radio]:checked'); + return checked ? checked.value : null; + } + case 'multi_check_box_question': { + const boxes = body.querySelectorAll('input[type=checkbox]:checked'); + return [...boxes].map(el => el.value); + } + case 'value_spinner': + case 'string_spinner': { + const sel = body.querySelector('select.qp-select'); + const val = sel?.value ?? ''; + return val === '' ? null : val; + } + case 'slider_question': { + const range = body.querySelector('input[type=range]'); + return range ? range.value : null; + } + case 'date_spinner': { + const precision = config.precision || 'full'; + const year = body.querySelector('[data-part=year]')?.value; + if (!year) return null; + if (precision === 'year') return year; + const month = body.querySelector('[data-part=month]')?.value || '01'; + if (precision === 'year_month') return `${year}-${month}`; + const day = body.querySelector('[data-part=day]')?.value || '01'; + return `${year}-${month}-${day}`; + } + case 'free_text': { + const text = body.querySelector('input[type=text], textarea')?.value?.trim(); + return text || null; + } + case 'glass_scale_question': { + const symptoms = config.symptoms || []; + if (!symptoms.length) return {}; + const result = {}; + for (const sym of symptoms) { + const picked = body.querySelector(`input[data-symptom="${CSS.escape(sym)}"]:checked`); + if (!picked) return null; + result[sym] = picked.value; + } + return result; + } + case 'client_coach_code_question': { + const client = body.querySelector('[data-field=client]')?.value?.trim(); + const coach = body.querySelector('[data-field=coach]')?.value?.trim(); + if (!client && !coach) return null; + return { client_code: client || '', coach_code: coach || '' }; + } + case 'last_page': + return true; + default: + return null; + } +} + +function hasAnswerInUi(q, sectionEl) { + const body = sectionEl?.querySelector('.qp-question-body'); + const layout = q.type || ''; + const config = parseConfig(q); + const answer = readAnswerFromBody(body, q); + + switch (layout) { + case 'radio_question': + return answer !== null; + case 'multi_check_box_question': { + const min = config.minSelection || 0; + const count = Array.isArray(answer) ? answer.length : 0; + return count >= Math.max(min, 0) && (count > 0 || !q.isRequired); + } + case 'value_spinner': + case 'string_spinner': + return answer !== null && answer !== ''; + case 'slider_question': + return answer !== null; + case 'date_spinner': { + const year = body?.querySelector('[data-part=year]')?.value; + if (!year) return false; + if (config.precision === 'year') return true; + const month = body?.querySelector('[data-part=month]')?.value; + if (!month) return false; + if (config.precision === 'year_month') return true; + return Boolean(body?.querySelector('[data-part=day]')?.value); + } + case 'free_text': + return Boolean(answer); + case 'glass_scale_question': { + const symptoms = config.symptoms || []; + if (!symptoms.length) return true; + return answer !== null && typeof answer === 'object' + && symptoms.every(s => answer[s]); + } + case 'client_coach_code_question': + return Boolean(answer?.client_code && answer?.coach_code); + case 'last_page': + return true; + default: + return true; + } +} + +function branchAnswerFromSection(q, sectionEl) { + if (hasBranchingOptions(q) && !hasAnswerInUi(q, sectionEl)) return null; + const body = sectionEl?.querySelector('.qp-question-body'); + return readAnswerFromBody(body, q); +} + +function visibleQuestionIndices(questions, sectionByIdx) { + const visible = new Set(); + let i = 0; + while (i < questions.length) { + const q = questions[i]; + if (q.type === 'last_page') { + i++; + continue; + } + visible.add(i); + const sectionEl = sectionByIdx.get(i); + const answer = sectionEl ? branchAnswerFromSection(q, sectionEl) : null; + const nextId = answer !== null ? resolveNextLocalId(q, answer) : null; + + if (nextId) { + const isLastPageTarget = questions.some( + qq => qq.type === 'last_page' && localId(qq) === nextId, + ); + if (isLastPageTarget) break; + const target = questions.findIndex(qq => localId(qq) === nextId); + i = target >= 0 ? target : i + 1; + } else if (hasBranchingOptions(q) && answer === null) { + break; + } else { + i++; + } + } + return visible; +} + +function symptomRowLabel(q, symptomKey) { + const rows = q.glassSymptoms || []; + const row = rows.find(r => r.key === symptomKey); + return (row?.labelDe || '').trim() || symptomKey; +} + +function renderQuestionBody(q, sectionIdx) { + const layout = q.type || ''; + const config = parseConfig(q); + const qText = q.defaultText || questionKey(q) || 'Question'; + const prefix = `qp_${sectionIdx}`; + + let inner = ''; + if (config.noteBefore) inner += `

        ${esc(config.noteBefore)}

        `; + inner += `

        ${esc(qText)}

        `; + + switch (layout) { + case 'radio_question': + inner += `
        `; + for (const o of (q.answerOptions || [])) { + const key = optionKeyOf(o); + inner += ``; + } + inner += `
        `; + break; + + case 'multi_check_box_question': + inner += `
        `; + for (const o of (q.answerOptions || [])) { + const key = optionKeyOf(o); + inner += ``; + } + if (config.otherOptionKey && config.otherNextQuestionId) { + const ok = config.otherOptionKey; + inner += ``; + } + inner += `
        `; + if (config.minSelection) { + inner += `

        Select at least ${config.minSelection} option(s).

        `; + } + break; + + case 'value_spinner': { + const values = valueSpinnerValues(config); + inner += ``; + break; + } + + case 'slider_question': { + const range = config.range || { min: 0, max: 100 }; + const min = Number(range.min ?? 0); + const max = Number(range.max ?? 100); + const step = Number(config.step ?? 1) || 1; + inner += ` +
        + + ${min}${config.unitLabel ? ' ' + esc(config.unitLabel) : ''} +
        `; + break; + } + + case 'date_spinner': { + const precision = config.precision || 'full'; + const years = []; + const now = new Date().getFullYear(); + for (let yr = now + 1; yr >= 1900; yr--) years.push(yr); + inner += `
        `; + if (precision === 'full') { + inner += ``; + } + if (precision !== 'year') { + inner += ``; + } + inner += `
        `; + break; + } + + case 'string_spinner': { + inner += ``; + break; + } + + case 'free_text': + inner += ``; + break; + + case 'glass_scale_question': { + const symptoms = config.symptoms || []; + const scaleOpts = glassScaleOptions(q); + if (!symptoms.length) { + inner += `

        Informational screen (no symptom rows).

        `; + } else { + inner += `
        `; + for (const so of scaleOpts) inner += ``; + inner += ``; + symptoms.forEach((sym, si) => { + inner += ``; + scaleOpts.forEach(so => { + inner += ``; + }); + inner += ``; + }); + inner += `
        Symptom${esc(so.label)}
        ${esc(symptomRowLabel(q, sym))}
        `; + } + break; + } + + case 'client_coach_code_question': + inner += ` +

        In the app, client and Counselor codes are loaded automatically — Counselor review them here, not type them in.

        +
        + + +
        `; + break; + + case 'last_page': + inner += `

        ${esc(config.textKey || 'End of questionnaire.')}

        `; + break; + + default: + inner += `

        Preview not available for layout ${esc(layout)}.

        `; + } + + if (config.noteAfter) inner += `

        ${esc(config.noteAfter)}

        `; + return inner; +} + +function layoutLabel(type) { + const labels = { + radio_question: 'Radio', + multi_check_box_question: 'Checkbox', + glass_scale_question: 'Glass scale', + value_spinner: 'Value spinner', + slider_question: 'Slider', + date_spinner: 'Date', + string_spinner: 'String spinner', + free_text: 'Free text', + client_coach_code_question: 'Client / Counselor code', + last_page: 'Last page', + }; + return labels[type] || type || 'Unknown'; +} + +/** + * @param {HTMLElement} container + * @param {{ questionnaireName?: string, questions: object[] }} opts + */ +export function mountQuestionnairePreview(container, opts) { + const questions = opts.questions || []; + + function render() { + if (!questions.length) { + container.innerHTML = ` +
        +

        No questions to preview. Add questions on the Questions tab first.

        +
        `; + return; + } + + const sections = questions.map((q, idx) => { + const lid = localId(q); + const num = idx + 1; + return ` + `; + }).join(''); + + container.innerHTML = ` +
        +
        Preview mode — all questions on one page. Answers are not saved.
        +
        + + +
        +
        +
        + ${sections} +
        +
        +
        `; + + wireEvents(); + updateVisibility(); + } + + function sectionElements() { + return [...container.querySelectorAll('.qp-section')]; + } + + function sectionByIdxMap() { + const map = new Map(); + for (const el of sectionElements()) { + map.set(parseInt(el.dataset.idx, 10), el); + } + return map; + } + + function updateVisibility() { + const map = sectionByIdxMap(); + const visible = visibleQuestionIndices(questions, map); + let shown = 0; + for (const el of sectionElements()) { + const idx = parseInt(el.dataset.idx, 10); + const isVisible = visible.has(idx); + el.hidden = !isVisible; + el.classList.toggle('qp-section-hidden', !isVisible); + if (isVisible) shown++; + } + const counter = container.querySelector('#qpVisibleCount'); + if (counter) { + counter.textContent = `Showing ${shown} of ${questions.length} questions`; + } + } + + function wireEvents() { + const form = container.querySelector('#qpForm'); + if (!form) return; + + form.addEventListener('input', () => updateVisibility()); + form.addEventListener('change', () => updateVisibility()); + + form.querySelectorAll('input[type=range]').forEach(slider => { + const output = slider.closest('.qp-slider')?.querySelector('.qp-slider-value'); + if (!output) return; + slider.addEventListener('input', () => { + output.textContent = slider.value; + }); + }); + + container.querySelector('[data-action=restart]')?.addEventListener('click', () => { + form.reset(); + form.querySelectorAll('input[type=radio], input[type=checkbox]').forEach(el => { + el.checked = false; + }); + form.querySelectorAll('.qp-slider-value').forEach((output, i) => { + const slider = form.querySelectorAll('input[type=range]')[i]; + if (slider) output.textContent = slider.value; + }); + updateVisibility(); + }); + } + + render(); + return { restart: () => container.querySelector('[data-action=restart]')?.click() }; +} diff --git a/website/js/router.js b/website/js/router.js new file mode 100644 index 0000000..3abd697 --- /dev/null +++ b/website/js/router.js @@ -0,0 +1,56 @@ +const routes = []; +let currentCleanup = null; + +export function addRoute(pattern, handler) { + let regex; + const paramNames = []; + if (pattern instanceof RegExp) { + regex = pattern; + } else { + const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => { + paramNames.push(name); + return '/([^/]+)'; + }); + regex = new RegExp('^' + parts + '$'); + } + routes.push({ regex, paramNames, handler }); +} + +export function navigate(hash) { + window.location.hash = hash; +} + +export function currentHash() { + return window.location.hash.slice(1) || '/'; +} + +async function resolve() { + if (typeof currentCleanup === 'function') { + currentCleanup(); + currentCleanup = null; + } + + const path = currentHash(); + for (const route of routes) { + const match = path.match(route.regex); + if (match) { + const params = {}; + route.paramNames.forEach((name, i) => { + params[name] = decodeURIComponent(match[i + 1]); + }); + try { + currentCleanup = await route.handler(params) || null; + } catch (e) { + console.error('Route handler error:', e); + } + return; + } + } + // fallback: home dashboard + navigate('#/'); +} + +export function startRouter() { + window.addEventListener('hashchange', resolve); + resolve(); +} diff --git a/website/js/table-filter.js b/website/js/table-filter.js new file mode 100644 index 0000000..8536871 --- /dev/null +++ b/website/js/table-filter.js @@ -0,0 +1,52 @@ +/** + * Client-side search for .data-table bodies. + * @param {HTMLTableSectionElement} tbody + * @param {string} query + * @param {(row: HTMLTableRowElement) => string} getRowText + * @returns {number} visible row count (excludes placeholder rows) + */ +export function filterTableBodyRows(tbody, query, getRowText) { + if (!tbody) return 0; + const q = query.trim().toLowerCase(); + let visible = 0; + tbody.querySelectorAll('tr').forEach(row => { + if (row.dataset.filterPlaceholder === '1') { + row.style.display = q ? 'none' : ''; + return; + } + const text = getRowText(row).toLowerCase(); + const show = !q || text.includes(q); + row.style.display = show ? '' : 'none'; + if (show) visible++; + }); + return visible; +} + +/** + * @param {HTMLInputElement} input + * @param {HTMLTableSectionElement} tbody + * @param {(row: HTMLTableRowElement) => string} getRowText + * @param {HTMLElement|null} countEl + * @param {{ total: number, noneLabel?: string }} [countOpts] + */ +export function wireTableSearch(input, tbody, getRowText, countEl, countOpts = {}) { + if (!input || !tbody) return; + const update = () => { + const visible = filterTableBodyRows(tbody, input.value, getRowText); + const total = countOpts.total ?? tbody.querySelectorAll('tr:not([data-filter-placeholder])').length; + if (!countEl) return; + const q = input.value.trim(); + if (!q) { + countEl.textContent = countOpts.noneLabel + ? `${total} ${countOpts.noneLabel}` + : ''; + return; + } + const noun = countOpts.noneLabel || 'rows'; + countEl.textContent = visible + ? `${visible} of ${total} ${noun}` + : `No ${noun} match`; + }; + input.addEventListener('input', update); + update(); +} diff --git a/website/js/test-data.js b/website/js/test-data.js new file mode 100644 index 0000000..d7a7111 --- /dev/null +++ b/website/js/test-data.js @@ -0,0 +1,125 @@ +const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; + +/** Local question id from full questionID (hash__q5 → q5). */ +export function questionLocalKey(questionID, fallback = '') { + const id = String(questionID ?? ''); + const sep = id.lastIndexOf('__'); + if (sep >= 0) return id.slice(sep + 2); + return fallback || id; +} + +function parseQuestionConfig(q) { + if (!q || typeof q !== 'object') return {}; + let cfg = q.configJson; + if (typeof cfg === 'string') { + try { + cfg = JSON.parse(cfg); + } catch { + cfg = {}; + } + } + return cfg && typeof cfg === 'object' ? cfg : {}; +} + +/** Stable question key for table headers / CSV (matches server qdb_question_column_label). */ +export function questionDisplayKey(q) { + if (!q) return ''; + if (q.questionKey) return String(q.questionKey); + const cfg = parseQuestionConfig(q); + const fromCfg = String(cfg.questionKey || '').trim(); + if (fromCfg && STABLE_KEY_RE.test(fromCfg)) return fromCfg; + const dt = String(q.defaultText || '').trim(); + if (dt && STABLE_KEY_RE.test(dt)) return dt; + return questionLocalKey(q.questionID, dt); +} + +/** German question text for header tooltips. */ +export function questionGermanLabel(q) { + const dt = String(q?.defaultText || '').trim(); + const key = questionDisplayKey(q); + if (dt && dt !== key) return dt; + return ''; +} + +/** One symptom value from parent glass-scale JSON answer. */ +export function glassSymptomAnswerValue(raw, symptomKey) { + if (!raw || !symptomKey) return ''; + try { + const o = JSON.parse(raw); + if (o && typeof o === 'object' && !Array.isArray(o)) { + const v = o[symptomKey]; + return v != null ? String(v).trim() : ''; + } + } catch { + /* ignore */ + } + return ''; +} + +/** + * Flat result/export columns: glass_scale questions → one column per symptom. + */ +export function buildResultColumns(questions) { + const cols = []; + for (const q of questions || []) { + const cfg = parseQuestionConfig(q); + const symptoms = cfg.symptoms; + if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) { + const parentKey = questionDisplayKey(q); + for (const sk of symptoms) { + const symptomKey = String(sk).trim(); + if (!symptomKey) continue; + cols.push({ + key: `glass_${q.questionID}_${symptomKey}`, + questionID: q.questionID, + symptomKey, + label: symptomKey, + title: parentKey && parentKey !== symptomKey + ? `${parentKey} · ${symptomKey}` + : symptomKey, + isGlassSymptom: true, + question: q, + }); + } + continue; + } + const label = questionDisplayKey(q); + cols.push({ + key: `q_${q.questionID}`, + questionID: q.questionID, + symptomKey: null, + label, + title: questionGermanLabel(q) || label, + isGlassSymptom: false, + question: q, + }); + } + return cols; +} + +/** Display text for one results table cell. */ +export function resultColumnCellValue(col, answer, optionMap = {}) { + if (!answer) return null; + if (col.isGlassSymptom) { + const v = glassSymptomAnswerValue(answer.freeTextValue, col.symptomKey); + return v || null; + } + const q = col.question; + if (answer.answerOptionID && optionMap[answer.answerOptionID]) { + return optionMap[answer.answerOptionID]; + } + if (answer.freeTextValue) return answer.freeTextValue; + if (answer.numericValue !== null && answer.numericValue !== undefined) { + return answer.numericValue; + } + return null; +} + +/** header_order.json column id → question key (questionnaire_x-y → y). */ +export function headerColumnQuestionKey(headerId) { + const id = String(headerId ?? ''); + if (id === 'client_code') return 'client_code'; + const dash = id.indexOf('-'); + if (dash < 0) return id; + return id.slice(dash + 1); +} diff --git a/website/js/translations-helpers.js b/website/js/translations-helpers.js new file mode 100644 index 0000000..f97c860 --- /dev/null +++ b/website/js/translations-helpers.js @@ -0,0 +1,285 @@ +/** German (de) is the canonical source language for questionnaire content. */ +export const SOURCE_LANG = 'de'; + +export function normalizeTranslations(raw) { + if (!raw) return {}; + if (Array.isArray(raw)) { + const map = {}; + raw.forEach(row => { + if (row && row.languageCode) map[row.languageCode] = row.text ?? ''; + }); + return map; + } + return typeof raw === 'object' ? { ...raw } : {}; +} + +export function normalizeEntry(entry) { + const translations = normalizeTranslations(entry.translations); + const germanText = (entry.germanText || translations[SOURCE_LANG] || entry.key || '').trim() + || entry.key + || ''; + return { ...entry, translations, germanText }; +} + +export function germanFor(entry) { + return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || ''; +} + +const HEX_ID_RE = /^[a-f0-9]{32}$/i; + +/** Key column label (short id or readable preview — not the app lookup key). */ +export function entryDisplayKey(entry) { + const dk = entry.displayKey || ''; + if (dk && !HEX_ID_RE.test(dk)) return dk; + if (entry.type === 'question' || entry.type === 'answer_option') { + const key = (entry.key || '').trim(); + if (key) { + return key.length > 56 ? `${key.slice(0, 56)}…` : key; + } + } + if (entry.type === 'question' || entry.type === 'answer_option') { + const id = entry.entityId || ''; + const sep = id.lastIndexOf('__'); + if (sep >= 0) return id.slice(sep + 2); + } + return dk || entry.key || ''; +} + +export function translationFor(entry, lang) { + return normalizeTranslations(entry.translations)[lang] || ''; +} + +export function isEntryMissing(entry, lang) { + return !translationFor(entry, lang).trim(); +} + +export function countMissing(entries, lang) { + return entries.filter(e => isEntryMissing(e, lang)).length; +} + +export function sectionEntries(allEntries, section) { + let entries = allEntries.slice(section.startIdx, section.startIdx + section.entryCount); + if (section.isApp) { + entries = entries.filter(e => e.type === 'app_string'); + } + return entries; +} + +export function targetLanguages(languages) { + return (languages || []).filter(l => l.languageCode !== SOURCE_LANG); +} + +/** Sort rows within a group: missing translations first, then questionnaire order */ +export function sortGroupItems(items, targetLang) { + return [...items].sort((a, b) => { + const aMissing = !translationFor(a.entry, targetLang).trim(); + const bMissing = !translationFor(b.entry, targetLang).trim(); + if (aMissing !== bMissing) return aMissing ? -1 : 1; + + const orderA = a.entry.sortOrder ?? 0; + const orderB = b.entry.sortOrder ?? 0; + if (orderA !== orderB) return orderA - orderB; + + const typeCmp = (a.entry.type === 'question' ? 0 : 1) - (b.entry.type === 'question' ? 0 : 1); + if (typeCmp !== 0) return typeCmp; + + return germanFor(a.entry).localeCompare(germanFor(b.entry), 'de'); + }); +} + +/** + * Groups for one questionnaire: UI strings, then questions + options (interleaved). + * @param {number} indexOffset - global index for save handlers + */ +export function buildTranslationGroups(entries, targetLang, indexOffset = 0) { + const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i })); + const appStrings = items.filter(({ entry }) => entry.type === 'app_string'); + const qStrings = items.filter(({ entry }) => entry.type === 'string'); + const content = items.filter(({ entry }) => + entry.type !== 'string' && entry.type !== 'app_string' + ); + const groups = []; + + if (appStrings.length) { + groups.push({ + id: 'app_strings', + title: 'App strings', + items: sortGroupItems(appStrings, targetLang), + }); + } + if (qStrings.length) { + groups.push({ + id: 'strings', + title: 'UI strings', + items: sortGroupItems(qStrings, targetLang), + }); + } + if (content.length) { + groups.push({ + id: 'content', + title: 'Questions', + items: sortGroupItems(content, targetLang), + }); + } + return groups; +} + +/** Render grouped sections (optional questionnaire wrapper title). */ +export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) { + const { showColumnHeader = true, questionnaireTitle = null } = options; + if (!groups.length) return ''; + + const header = showColumnHeader ? translationListHeaderHTML( + targetLang, + sourceLabel + ) : ''; + + const qnHeader = questionnaireTitle + ? `

        ${escHtml(questionnaireTitle)}

        ` + : ''; + + const body = groups.map(g => ` +
        +

        ${escHtml(g.title)}

        +
        + ${g.items.map(({ entry, idx }) => + translationRowHTML(entry, idx, { targetLang, editable }) + ).join('')} +
        +
        + `).join(''); + + const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap'; + return `
        ${qnHeader}${header}${body}
        `; +} + +/** Only global app UI rows (never questionnaire questions/options). */ +export function filterGlobalAppEntries(entries) { + return (entries || []).filter(e => e && e.type === 'app_string'); +} + +/** Flatten app strings + questionnaires from ?all=1 */ +export function flattenAllQuestionnaires(questionnaires, appStrings = []) { + const allEntries = []; + const sections = []; + + const appNorm = filterGlobalAppEntries((appStrings || []).map(normalizeEntry)); + if (appNorm.length) { + const startIdx = 0; + appNorm.forEach(e => allEntries.push(e)); + sections.push({ + questionnaireID: '__app__', + name: 'App UI', + startIdx, + entryCount: appNorm.length, + isApp: true, + }); + } + + for (const qn of questionnaires) { + const normalized = (qn.entries || []).map(normalizeEntry); + if (!normalized.length) continue; + const startIdx = allEntries.length; + normalized.forEach(e => allEntries.push(e)); + sections.push({ + questionnaireID: qn.questionnaireID, + name: qn.name || 'Questionnaire', + startIdx, + entryCount: normalized.length, + isApp: false, + }); + } + + return { allEntries, sections }; +} + +export function typeBadge(type) { + const map = { question: 'Q', answer_option: 'Opt', string: 'Str', app_string: 'App' }; + const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str', app_string: 'badge-app' }; + return `${map[type] || type}`; +} + +export function escHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function sourceLanguageLabel(languages) { + const row = (languages || []).find(l => l.languageCode === SOURCE_LANG); + return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German'; +} + +/** Column headers: label | main language (German) | target language */ +export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') { + return ` +
        + Label + ${escHtml(sourceLabel)} + ${escHtml(targetLangLabel)} +
        `; +} + +export function translationRowHTML(entry, idx, { targetLang, editable = true }) { + const lookupKey = entry.key || ''; + const label = entryDisplayKey(entry); + const german = germanFor(entry); + const val = translationFor(entry, targetLang); + const missing = !val.trim(); + const disabled = editable ? '' : 'disabled'; + + const sourceCell = editable + ? `` + : `${escHtml(german)}`; + + const germanPreview = german.length > 72 ? `${german.slice(0, 72)}…` : german; + + return ` +
        +
        + ${typeBadge(entry.type)} +
        + ${escHtml(germanPreview || label)} + ${escHtml(lookupKey)} +
        +
        +
        ${sourceCell}
        + +
        `; +} + +/** + * Grouped rows with collapsible sections and missing counts in summaries. + */ +export function renderCollapsibleGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) { + const { openGroupsWithMissing = false } = options; + if (!groups.length) return ''; + + const body = groups.map(g => { + const missing = g.items.filter(({ entry }) => isEntryMissing(entry, targetLang)).length; + const total = g.items.length; + const open = openGroupsWithMissing && missing > 0; + const summary = missing + ? `${g.title} · ${missing} missing` + : `${g.title} · complete (${total})`; + + return ` +
        + ${escHtml(summary)} +
        + ${g.items.map(({ entry, idx }) => + translationRowHTML(entry, idx, { targetLang, editable }) + ).join('')} +
        +
        `; + }).join(''); + + return `
        ${body}
        `; +} diff --git a/website/media/UniKonstanz_Logo_Optimum_RGB.eps b/website/media/UniKonstanz_Logo_Optimum_RGB.eps new file mode 100644 index 0000000..d0860c0 Binary files /dev/null and b/website/media/UniKonstanz_Logo_Optimum_RGB.eps differ diff --git a/website/media/uni-logo.png b/website/media/uni-logo.png new file mode 100644 index 0000000..4bc2098 Binary files /dev/null and b/website/media/uni-logo.png differ diff --git a/website/temp-assets/questionnaire_0_readme.json b/website/temp-assets/questionnaire_0_readme.json new file mode 100644 index 0000000..228c980 --- /dev/null +++ b/website/temp-assets/questionnaire_0_readme.json @@ -0,0 +1,158 @@ +{ + "meta": { + "_comment": "Diese Zeiel erklärt im folgenden wie die einzelnen Fragetypen definiert werden können und was für features diese mit sich bringen.", + "id": "questionnaire_0_readme" + }, + "questions": [ + + + { + "id": "q1", + "_comment": "Frage mit Radio Buttons. In diesem Fragetypen kann man maximal eine Antwort wählen. Über nextQuestionId kann man die nächste Frage definieren., Über pointsMap kann man die Punkte für die Antwort der Frage festlegen.", + "layout": "radio_question", + "question": "consent_instruction", + "options": [ + { "key": "consent_signed"}, + { "key": "consent_not_signed", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + + + { + "id": "q2", + "_comment": "Client- und Coach-Code (z. B. wenn Einwilligung nicht unterschrieben).", + "layout": "client_coach_code_question", + "question": "no_consent_entered", + "hint1": "client_code", + "hint2": "coach_code" + }, + + + { + "id": "q3", + "_comment": "Frage mit Spinner, dessen Antwortmöglichkeiten über options selbst definiert werden können.", + "layout": "string_spinner", + "question": "other_accommodation", + "options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"] + }, + + + { + "id": "q3b", + "_comment": "Freitext-Eingabe (einzeilig oder mehrzeilig). maxLength und hint sind optional. Eingaben werden gegen SQL-Injection-Muster gefiltert.", + "layout": "free_text", + "question": "other_accommodation", + "hint": "enter_text_to_continue", + "maxLength": 500, + "multiline": true + }, + + + { + "id": "q4", + "_comment": "Datumsfrage. precision: full (YYYY-MM-DD), year_month (YYYY-MM), year (YYYY).", + "layout": "date_spinner", + "question": "departure_country", + "precision": "year_month" + }, + + + { + "id": "q5", + "_comment": "Datumsfrage mit constraints. precision steuert Tag/Monat/Jahr-Spinner.", + "layout": "date_spinner", + "question": "since_in_germany", + "precision": "year_month", + "constraints": { + "notBefore": "departure_country", + "notAfter": "departure_country" + } + }, + + + { + "id": "q5b", + "_comment": "Glas-Skala (Symptome als Zeilen, 5 Stufen). scaleType: glass (Standard) oder thermometer.", + "layout": "glass_scale_question", + "question": "example_symptom_scale", + "scaleType": "thermometer", + "symptoms": ["symptom_a", "symptom_b"] + }, + + + { + "id": "q6", + "_comment": "Frage mit Spinner, dessen Antwortmöglichkeiten über range selbst definiert werden können.", + "layout": "value_spinner", + "question": "number_family_members", + "range": { + "min": 1, + "max": 15 + } + }, + + + { + "id": "q7", + "_comment": "Frage nach dem Code des Klienten und den Code des Coaches.", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + + + { + "id": "q8", + "_comment": "Frage mit Checkboxen, dessen Antwortmöglichkeiten über options selbst definiert werden können. Über pointsMap können die Punkte für die jeweiligen Antworten festgelegt werden. Über minSelection kann die minimal Anzahl an auszufüllenden Antworten festgelegt werden.", + "layout": "multi_check_box_question", + "question": "languages_spoken", + "options": [ + { "key": "language_albanian" }, + { "key": "language_pashto" }, + { "key": "language_russian" }, + { "key": "language_other"} + ], + "pointsMap": { + "language_albanian": 1, + "language_pashto": 1, + "language_russian": 1, + "language_other": 1 + }, + "minSelection": 1 + }, + + + { + "id": "q9", + "_comment":"Glass scale frage, bei der es für jede Frage, welche über symptoms festgelegt werden kann eine Antwort festgelegt werden muss. Die Antwort ganz links gibt immer 0 Punkte und die Antwort ganz rechts gibt immer 4 Punkte.", + "layout": "glass_scale_question", + "question": "glass_explanation", + "symptoms": [ + "q1_symptom", + "q2_symptom", + "q3_symptom", + "q4_symptom", + "q5_symptom", + "q6_symptom", + "q7_symptom", + "q8_symptom", + "q9_symptom" + ] + }, + + + { + "id": "last_page", + "_comment":"Letzte Seite, welche den Abschluss des Fragebogens markiert.", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} diff --git a/website/temp-assets/questionnaire_1_demographic_information.json b/website/temp-assets/questionnaire_1_demographic_information.json new file mode 100644 index 0000000..4eaf298 --- /dev/null +++ b/website/temp-assets/questionnaire_1_demographic_information.json @@ -0,0 +1,306 @@ +{ + "meta": { + "id": "questionnaire_1_demographic_information" + }, + "questions": [ + { + "id": "q1", + "layout": "radio_question", + "question": "consent_instruction", + "options": [ + { "key": "consent_signed", + "nextQuestionId": "q6"}, + { "key": "consent_not_signed", + "nextQuestionId": "q2"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + { + "id": "q2", + "layout": "client_coach_code_question", + "question": "no_consent_entered", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + }, + { + "id": "q28", + "layout": "radio_question", + "question": "accommodation", + "options": [ + { "key": "steinstrasse", + "nextQuestionId": "last_page"}, + { "key": "doerfle", + "nextQuestionId": "last_page"}, + { "key": "zoll_emmishofer", + "nextQuestionId": "last_page"}, + { "key": "other", + "nextQuestionId": "q4"} + ] + }, + { + "id": "q4", + "layout": "string_spinner", + "question": "other_accommodation", + "options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"] + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + }, + { + "id": "q6", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "q29", + "layout": "radio_question", + "question": "accommodation", + "options": [ + { "key": "steinstrasse", + "nextQuestionId": "q9"}, + { "key": "doerfle", + "nextQuestionId": "q9"}, + { "key": "zoll_emmishofer", + "nextQuestionId": "q9"}, + { "key": "other", + "nextQuestionId": "q8"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + { + "id": "q8", + "layout": "string_spinner", + "question": "other_accommodation", + "options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"] + }, + { + "id": "q9", + "layout": "value_spinner", + "question": "age", + "range": { + "min": 18, + "max": 122 + } + }, + { + "id": "q10", + "layout": "radio_question", + "question": "gender", + "options": [ + { "key": "gender_male"}, + { "key": "gender_female"}, + { "key": "gender_diverse"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + { + "id": "q11", + "layout": "string_spinner", + "question": "country_of_origin" + }, + { + "id": "q13", + "layout": "date_spinner", + "question": "departure_country", + "precision": "year_month" + }, + { + "id": "q14", + "layout": "date_spinner", + "question": "since_in_germany", + "precision": "year_month", + "constraints": { + "notBefore": "departure_country" + } + }, + { + "id": "q16", + "layout": "radio_question", + "question": "living_situation", + "options": [ + { "key": "alone", + "nextQuestionId": "q18"}, + { "key": "with_family", + "nextQuestionId": "q17"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + { + "id": "q17", + "layout": "value_spinner", + "question": "number_family_members", + "range": { + "min": 1, + "max": 15 + } + }, + { + "id": "q18", + "layout": "multi_check_box_question", + "question": "languages_spoken", + "options": [ + { "key": "language_albanian" }, + { "key": "language_pashto" }, + { "key": "language_russian" }, + { "key": "language_serbo" }, + { "key": "language_somali" }, + { "key": "language_tamil" }, + { "key": "language_tigrinya" }, + { "key": "language_turkish" }, + { "key": "language_ukrainian" }, + { "key": "language_urdu" }, + { "key": "language_arabic" }, + { "key": "language_dari_farsi" }, + { "key": "language_chinese" }, + { "key": "language_english" }, + { "key": "language_macedonian" }, + { "key": "language_kurmanji" }, + { "key": "language_hindi" }, + { "key": "language_french"}, + { "key": "language_other"} + ], + "pointsMap": { + "language_albanian": 0, + "language_pashto": 0, + "language_russian": 0, + "language_serbo": 0, + "language_somali": 0, + "language_tamil": 0, + "language_tigrinya": 0, + "language_turkish": 0, + "language_ukrainian": 0, + "language_urdu": 0, + "language_arabic": 0, + "language_dari_farsi": 0, + "language_chinese": 0, + "language_english": 0, + "language_macedonian": 0, + "language_kurmanji": 0, + "language_hindi": 0, + "language_french": 0, + "language_other": 0 + }, + "minSelection": 1 + }, + { + "id": "q19", + "layout": "radio_question", + "question": "german_skills", + "options": [ + { "key": "skill_none"}, + { "key": "skill_basic"}, + { "key": "skill_a1"}, + { "key": "skill_a2"}, + { "key": "skill_b1"}, + { "key": "skill_b2"}, + { "key": "skill_c1"}, + { "key": "skill_c2"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + { + "id": "q30", + "layout": "value_spinner", + "question": "school_years_total", + "range": { + "min": 0, + "max": 15 + }, + "options": [ + { "value": 0, "nextQuestionId": "q23" } + ] + }, + + { + "id": "q20", + "layout": "value_spinner", + "question": "school_years_origin", + "range": { + "min": 0, + "max": 15 + } + }, + { + "id": "q21", + "layout": "value_spinner", + "question": "school_years_transit", + "range": { + "min": 0, + "max": 15 + } + }, + { + "id": "q22", + "layout": "value_spinner", + "question": "school_years_germany", + "range": { + "min": 0, + "max": 15 + } + }, + { + "id": "q23", + "layout": "radio_question", + "question": "vocational_training", + "options": [ + { "key": "answer_no"}, + { "key": "answer_started"}, + { "key": "answer_completed"} + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + }, + { + "id": "q24", + "layout": "date_spinner", + "question": "provisional_accommodation_since", + "precision": "year_month", + "constraints": { + "notBefore": "since_in_germany" + } + }, + { + "id": "q25", + "layout": "date_spinner", + "question": "asylum_procedure_since", + "precision": "year_month", + "constraints": { + "notBefore": "since_in_germany" + } + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} diff --git a/website/temp-assets/questionnaire_2_rhs.json b/website/temp-assets/questionnaire_2_rhs.json new file mode 100644 index 0000000..4699964 --- /dev/null +++ b/website/temp-assets/questionnaire_2_rhs.json @@ -0,0 +1,137 @@ +{ + "meta": { + "id": "questionnaire_2_rhs" + }, + "questions": [ + { + "id": "q0", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "q3", + "layout": "glass_scale_question", + "question": "glass_explanation", + "symptoms": [ + "q1_symptom", + "q2_symptom", + "q3_symptom", + "q4_symptom", + "q5_symptom", + "q6_symptom", + "q7_symptom", + "q8_symptom", + "q9_symptom" + ] + }, + { + "id": "q4", + "layout": "glass_scale_question", + "question": "how_strong_past_month", + "symptoms": [ + "q10_reexperience_trauma", + "q11_physical_reaction", + "q12_emotional_numbness", + "q13_easily_startled" + ] + }, + { + "id": "q5", + "layout": "radio_question", + "textKey": "resilience_reflection_prompt", + "question": "q14_intro", + "options": [ + { "key": "resilience_fully_capable" }, + { "key": "resilience_mostly_capable" }, + { "key": "resilience_some_capable_some_not" }, + { "key": "resilience_mostly_not_capable" }, + { "key": "resilience_not_capable" } + ], + "pointsMap": { + "resilience_fully_capable": 0, + "resilience_mostly_capable": 1, + "resilience_some_capable_some_not": 2, + "resilience_mostly_not_capable": 3, + "resilience_not_capable": 4 + } + }, + { + "id": "q6", + "layout": "value_spinner", + "question": "pain_rating_instruction", + "range": { + "min": 0, + "max": 10 + } + }, + { + "id": "q7", + "layout": "radio_question", + "question": "violence_question_1", + "options": [ + { "key": "no", + "nextQuestionId": "q10"}, + { "key": "yes" } + ] + }, + { + "id": "q8", + "layout": "radio_question", + "question": "times_happend", + "options": [ + { "key": "once" }, + { "key": "multiple_times" } + ] + }, + { + "id": "q9", + "layout": "value_spinner", + "question": "age_at_incident", + "range": { + "min": 1, + "max": 122 + } + }, + { + "id": "q10", + "layout": "radio_question", + "question": "conflict_since_arrival", + "options": [ + { "key": "no", + "nextQuestionId": "last_page"}, + { "key": "yes" } + ] + }, + { + "id": "q11", + "layout": "radio_question", + "question": "times_happend2", + "options": [ + { "key": "once" }, + { "key": "multiple_times" } + ] + }, + { + "id": "q12", + "layout": "value_spinner", + "question": "age_at_incident2", + "range": { + "min": 1, + "max": 122 + } + }, + { + "id": "q25", + "layout": "date_spinner", + "question": "asylum_procedure_since" + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} diff --git a/website/temp-assets/questionnaire_3_integration_index.json b/website/temp-assets/questionnaire_3_integration_index.json new file mode 100644 index 0000000..a0498ab --- /dev/null +++ b/website/temp-assets/questionnaire_3_integration_index.json @@ -0,0 +1,255 @@ +{ + "meta": { + "id": "questionnaire_3_integration_index" + }, + "questions": [ + { + "id": "q0", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "q1", + "layout": "radio_question", + "textKey": "intro_life_in_germany", + "question": "feeling_connected_to_germany_question", + "options": [ + { "key": "very_connected" }, + { "key": "quite_connected" }, + { "key": "moderately_connected" }, + { "key": "somewhat_loose" }, + { "key": "not_connected_at_all" } + ], + "pointsMap": { + "very_connected": 3, + "quite_connected": 4, + "moderately_connected": 5, + "somewhat_loose": 1, + "not_connected_at_all": 2 + } + }, + { + "id": "q2", + "layout": "radio_question", + "question": "understanding_political_issues", + "options": [ + { "key": "very_good" }, + { "key": "good" }, + { "key": "fairly_good" }, + { "key": "somewhat" }, + { "key": "not_at_all" } + ], + "pointsMap": { + "very_good": 1, + "good": 2, + "fairly_good": 3, + "somewhat": 4, + "not_at_all": 5 + } + }, + { + "id": "q3", + "layout": "radio_question", + "question": "unexpected_expense_question", + "options": [ + { "key": "expense_50" }, + { "key": "expense_100" }, + { "key": "expense_300" }, + { "key": "expense_500" }, + { "key": "expense_800" } + ], + "pointsMap": { + "expense_50": 1, + "expense_100": 2, + "expense_300": 3, + "expense_500": 4, + "expense_800": 5 + } + }, + { + "id": "q4", + "layout": "radio_question", + "question": "dining_with_germans_question", + "options": [ + { "key": "never" }, + { "key": "once_a_year" }, + { "key": "once_a_month" }, + { "key": "once_a_week" }, + { "key": "almost_every_day" } + ], + "pointsMap": { + "never": 1, + "once_a_year": 2, + "once_a_month": 3, + "once_a_week": 4, + "almost_every_day": 5 + } + }, + { + "id": "q5", + "layout": "radio_question", + "question": "reading_german_articles_question", + "options": [ + { "key": "very_good" }, + { "key": "good" }, + { "key": "moderately_good" }, + { "key": "not_good" }, + { "key": "not_at_all" } + ], + "pointsMap": { + "very_good": 1, + "good": 2, + "moderately_good": 3, + "not_good": 4, + "not_at_all": 5 + } + }, + { + "id": "q6", + "layout": "radio_question", + "question": "visiting_doctor_question", + "options": [ + { "key": "very_difficult" }, + { "key": "rather_difficult" }, + { "key": "neither_nor" }, + { "key": "rather_easy" }, + { "key": "very_easy" } + ], + "pointsMap": { + "very_difficult": 1, + "rather_difficult": 2, + "neither_nor": 3, + "rather_easy": 4, + "very_easy": 5 + } + }, + { + "id": "q7", + "layout": "radio_question", + "question": "feeling_as_outsider_question", + "options": [ + { "key": "never" }, + { "key": "rarely" }, + { "key": "sometimes" }, + { "key": "often" }, + { "key": "always" } + ], + "pointsMap": { + "never": 1, + "rarely": 2, + "sometimes": 3, + "often": 4, + "always": 5 + } + }, + { + "id": "q8", + "layout": "radio_question", + "question": "recent_occupation_question", + "options": [ + { "key": "employed" }, + { "key": "education" }, + { "key": "internship" }, + { "key": "unemployed_searching" }, + { "key": "unemployed_not_searching" }, + { "key": "sick_or_disabled" }, + { "key": "unpaid_housework" }, + { "key": "other_activity" } + ], + "pointsMap": { + "employed": 1, + "education": 2, + "internship": 3, + "unemployed_searching": 4, + "unemployed_not_searching": 8, + "sick_or_disabled": 5, + "unpaid_housework": 6, + "other_activity": 7 + } + }, + { + "id": "q9", + "layout": "radio_question", + "question": "discussing_politics_question", + "options": [ + { "key": "never" }, + { "key": "once_a_year" }, + { "key": "once_a_month" }, + { "key": "once_a_week" }, + { "key": "almost_every_day" } + ], + "pointsMap": { + "never": 1, + "once_a_year": 2, + "once_a_month": 3, + "once_a_week": 4, + "almost_every_day": 5 + } + }, + { + "id": "q10", + "layout": "radio_question", + "question": "contact_with_germans_question", + "options": [ + { "key": "zero" }, + { "key": "one_to_three" }, + { "key": "three_to_six" }, + { "key": "seven_to_fourteen" }, + { "key": "fifteen_or_more" } + ], + "pointsMap": { + "zero": 1, + "one_to_three": 2, + "three_to_six": 3, + "seven_to_fourteen": 4, + "fifteen_or_more": 5 + } + }, + { + "id": "q11", + "layout": "radio_question", + "question": "speaking_german_opinion_question", + "options": [ + { "key": "very_good" }, + { "key": "good" }, + { "key": "moderately_good" }, + { "key": "not_good" }, + { "key": "not_at_all" } + ], + "pointsMap": { + "very_good": 1, + "good": 2, + "moderately_good": 3, + "not_good": 4, + "not_at_all": 5 + } + }, + { + "id": "q12", + "layout": "radio_question", + "question": "job_search_question", + "options": [ + { "key": "very_difficult" }, + { "key": "rather_difficult" }, + { "key": "neither_nor" }, + { "key": "rather_easy" }, + { "key": "very_easy" } + ], + "pointsMap": { + "very_difficult": 1, + "rather_difficult": 2, + "neither_nor": 3, + "rather_easy": 4, + "very_easy": 5 + } + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} diff --git a/website/temp-assets/questionnaire_4_consultation_results.json b/website/temp-assets/questionnaire_4_consultation_results.json new file mode 100644 index 0000000..b8a32aa --- /dev/null +++ b/website/temp-assets/questionnaire_4_consultation_results.json @@ -0,0 +1,182 @@ +{ + "meta": { + "id": "questionnaire_4_consultation_results" + }, + "questions": [ + { + "id": "q0", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "q1", + "layout": "date_spinner", + "question": "date_consultation_health_interview_result" + }, + { + "id": "q2", + "layout": "radio_question", + "question": "consultation_decision", + "options": [ + { + "key": "green", + "nextQuestionId": "q3"}, + {"key": "yellow", + "nextQuestionId": "q4"}, + { "key": "red", + "nextQuestionId": "q9"} + ], + "pointsMap": { + "green": 0, + "yellow": 0, + "red": 0 + } + }, + { + "id": "q3", + "layout": "radio_question", + "question": "consent_conversation_in_6_months", + "options": [ + { "key": "yes", + "nextQuestionId": "last_page"}, + { "key": "no", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "q4", + "layout": "radio_question", + "question": "participation_in_coaching", + "options": [ + {"key": "yes", + "nextQuestionId": "q5"}, + {"key": "no", + "nextQuestionId": "q6"}, + {"key": "time_to_think_about_it", + "nextQuestionId": "q7"} + ], + "pointsMap": { + "yes": 0, + "no": 0, + "time_to_think_about_it": 0 + } + }, + { + "id": "q5", + "layout": "radio_question", + "question": "consent_coaching_given", + "options": [ + {"key": "consent_yes", + "nextQuestionId": "q3"}, + {"key": "consent_no", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "consent_yes": 0, + "consent_no": 0 + } + }, + { + "id": "q6", + "layout": "radio_question", + "question": "consent_conversation_in_6_months", + "options": [ + { "key": "yes", + "nextQuestionId": "last_page"}, + { "key": "no", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "q7", + "layout": "date_spinner", + "question": "decision_after_reflection_period" + }, + { + "id": "q8", + "layout": "radio_question", + "question": "consent_conversation_in_6_months", + "options": [ + { "key": "yes", + "nextQuestionId": "last_page"}, + { "key": "no", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "q9", + "layout": "radio_question", + "question": "professional_referral", + "options": [ + { "key": "yes"}, + { "key": "no"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "q10", + "layout": "radio_question", + "question": "confidentiality_agreement" , + "options": [ + { "key": "available_yes"}, + { "key": "available_no", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "q11", + "layout": "radio_question", + "question": "health_insurance_card", + "options": [ + { "key": "yes"}, + { "key": "no"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "q12", + "layout": "radio_question", + "question": "consent_conversation_in_6_months", + "options": [ + { "key": "yes", + "nextQuestionId": "last_page"}, + { "key": "no", + "nextQuestionId": "last_page"} + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} \ No newline at end of file diff --git a/website/temp-assets/questionnaire_5_final_interview.json b/website/temp-assets/questionnaire_5_final_interview.json new file mode 100644 index 0000000..e017b65 --- /dev/null +++ b/website/temp-assets/questionnaire_5_final_interview.json @@ -0,0 +1,84 @@ +{ + "meta": { + "id": "questionnaire_5_final_interview" + }, + "questions": [ + { + "id": "q0", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "q1", + "layout": "radio_question", + "question": "consent_followup_6_months", + "options": [ + { "key": "yes" }, + { "key": "no" } + ] + }, + { + "id": "q2", + "layout": "date_spinner", + "question": "date_final_interview" + }, + { + "id": "q3", + "layout": "value_spinner", + "question": "amount_nat_appointments", + "range": { + "min": 0, + "max": 20 + } + }, + { + "id": "q4", + "layout": "value_spinner", + "question": "amount_session_flowers", + "range": { + "min": 0, + "max": 20 + } + }, + { + "id": "q5", + "layout": "value_spinner", + "question": "amount_session_stones", + "range": { + "min": 0, + "max": 20 + } + }, + { + "id": "q6", + "layout": "radio_question", + "question": "termination_nat_coaching", + "options": [ + {"key": "termination_nat_regular", + "nextQuestionId": "last_page"}, + {"key": "termination_nat_referral", + "nextQuestionId": "last_page"}, + {"key": "termination_nat_dropout", + "nextQuestionId": "q7"} + ] + }, + { + "id": "q7", + "layout": "radio_question", + "question": "client_canceled_NAT", + "options": [ + {"key": "after_meeting"}, + {"key": "without_giving_reasons"}, + {"key": "with_reasons_given"} + ] + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} diff --git a/website/temp-assets/questionnaire_6_follow_up_survey.json b/website/temp-assets/questionnaire_6_follow_up_survey.json new file mode 100644 index 0000000..8680a7e --- /dev/null +++ b/website/temp-assets/questionnaire_6_follow_up_survey.json @@ -0,0 +1,349 @@ +{ + "meta": { + "id": "questionnaire_6_follow_up_survey" + }, + "questions": [ + { + "id": "q0", + "layout": "client_coach_code_question", + "question": "client_code_entry_question", + "hint1": "client_code", + "hint2": "coach_code" + }, + { + "id": "q1", + "layout": "radio_question", + "question": "follow_up", + "options": [ + { + "key": "follow_up_completed", + "nextQuestionId": "q2" + }, + { + "key": "follow_up_failed_contact", + "nextQuestionId": "last_page" + }, + { + "key": "follow_up_consent_withdrawn", + "nextQuestionId": "last_page" + } + ], + "pointsMap": { + "follow_up_completed": 0, + "follow_up_failed_contact": 0, + "follow_up_consent_withdrawn": 0 + } + }, + { + "id": "q2", + "layout": "radio_question", + "question": "special_burden_question", + "options": [ + { "key": "yes" }, + { "key": "no" } + ], + "pointsMap": { + "yes": 0, + "no": 0 + } + }, + + { + "id": "q3", + "layout": "glass_scale_question", + "question": "glass_explanation", + "symptoms": [ + "q1_symptom", + "q2_symptom", + "q3_symptom", + "q4_symptom", + "q5_symptom", + "q6_symptom", + "q7_symptom", + "q8_symptom", + "q9_symptom" + ] + }, + { + "id": "q4", + "layout": "glass_scale_question", + "question": "how_strong_past_month", + "symptoms": [ + "q10_reexperience_trauma", + "q11_physical_reaction", + "q12_emotional_numbness", + "q13_easily_startled" + ] + }, + { + "id": "q5", + "layout": "radio_question", + "textKey": "resilience_reflection_prompt", + "question": "q14_intro", + "options": [ + { "key": "resilience_fully_capable" }, + { "key": "resilience_mostly_capable" }, + { "key": "resilience_some_capable_some_not" }, + { "key": "resilience_mostly_not_capable" }, + { "key": "resilience_not_capable" } + ], + "pointsMap": { + "resilience_fully_capable": 0, + "resilience_mostly_capable": 0, + "resilience_some_capable_some_not": 0, + "resilience_mostly_not_capable": 0, + "resilience_not_capable": 0 + } + }, + { + "id": "q6", + "layout": "value_spinner", + "question": "pain_rating_instruction", + "range": { + "min": 0, + "max": 10 + } + }, + { + "id": "q7", + "layout": "radio_question", + "textKey": "intro_life_in_germany", + "question": "feeling_connected_to_germany_question", + "options": [ + { "key": "very_connected" }, + { "key": "quite_connected" }, + { "key": "moderately_connected" }, + { "key": "somewhat_loose" }, + { "key": "not_connected_at_all" } + ], + "pointsMap": { + "very_connected": 0, + "quite_connected": 0, + "moderately_connected": 0, + "somewhat_loose": 0, + "not_connected_at_all": 0 + } + }, + { + "id": "q8", + "layout": "radio_question", + "question": "understanding_political_issues", + "options": [ + { "key": "very_good" }, + { "key": "good" }, + { "key": "fairly_good" }, + { "key": "somewhat" }, + { "key": "not_at_all" } + ], + "pointsMap": { + "very_good": 0, + "good": 0, + "fairly_good": 0, + "somewhat": 0, + "not_at_all": 0 + } + }, + { + "id": "q9", + "layout": "radio_question", + "question": "unexpected_expense_question", + "options": [ + { "key": "expense_50" }, + { "key": "expense_100" }, + { "key": "expense_300" }, + { "key": "expense_500" }, + { "key": "expense_800" } + ], + "pointsMap": { + "expense_50": 0, + "expense_100": 0, + "expense_300": 0, + "expense_500": 0, + "expense_800": 0 + } + }, + { + "id": "q10", + "layout": "radio_question", + "question": "dining_with_germans_question", + "options": [ + { "key": "never" }, + { "key": "once_a_year" }, + { "key": "once_a_month" }, + { "key": "once_a_week" }, + { "key": "almost_every_day" } + ], + "pointsMap": { + "never": 0, + "once_a_year": 0, + "once_a_month": 0, + "once_a_week": 0, + "almost_every_day": 0 + } + }, + { + "id": "q11", + "layout": "radio_question", + "question": "reading_german_articles_question", + "options": [ + { "key": "very_good" }, + { "key": "good" }, + { "key": "moderately_good" }, + { "key": "not_good" }, + { "key": "not_at_all" } + ], + "pointsMap": { + "very_good": 0, + "good": 0, + "moderately_good": 0, + "not_good": 0, + "not_at_all": 0 + } + }, + { + "id": "q12", + "layout": "radio_question", + "question": "visiting_doctor_question", + "options": [ + { "key": "very_difficult" }, + { "key": "rather_difficult" }, + { "key": "neither_nor" }, + { "key": "rather_easy" }, + { "key": "very_easy" } + ], + "pointsMap": { + "very_difficult": 0, + "rather_difficult": 0, + "neither_nor": 0, + "rather_easy": 0, + "very_easy": 0 + } + }, + { + "id": "q13", + "layout": "radio_question", + "question": "feeling_as_outsider_question", + "options": [ + { "key": "never" }, + { "key": "rarely" }, + { "key": "sometimes" }, + { "key": "often" }, + { "key": "always" } + ], + "pointsMap": { + "never": 0, + "rarely": 0, + "sometimes": 0, + "often": 0, + "always": 0 + } + }, + { + "id": "q14", + "layout": "radio_question", + "question": "recent_occupation_question", + "options": [ + { "key": "employed" }, + { "key": "education" }, + { "key": "internship" }, + { "key": "unemployed_searching" }, + { "key": "unemployed_not_searching" }, + { "key": "sick_or_disabled" }, + { "key": "unpaid_housework" }, + { "key": "other_activity" } + ], + "pointsMap": { + "employed": 0, + "education": 0, + "internship": 0, + "unemployed_searching": 0, + "unemployed_not_searching": 0, + "sick_or_disabled": 0, + "unpaid_housework": 0, + "other_activity": 0 + } + }, + { + "id": "q15", + "layout": "radio_question", + "question": "discussing_politics_question", + "options": [ + { "key": "never" }, + { "key": "once_a_year" }, + { "key": "once_a_month" }, + { "key": "once_a_week" }, + { "key": "almost_every_day" } + ], + "pointsMap": { + "never": 0, + "once_a_year": 0, + "once_a_month": 0, + "once_a_week": 0, + "almost_every_day": 0 + } + }, + { + "id": "q16", + "layout": "radio_question", + "question": "contact_with_germans_question", + "options": [ + { "key": "zero" }, + { "key": "one_to_three" }, + { "key": "three_to_six" }, + { "key": "seven_to_fourteen" }, + { "key": "fifteen_or_more" } + ], + "pointsMap": { + "zero": 0, + "one_to_three": 0, + "three_to_six": 0, + "seven_to_fourteen": 0, + "fifteen_or_more": 0 + } + }, + { + "id": "q17", + "layout": "radio_question", + "question": "speaking_german_opinion_question", + "options": [ + { "key": "very_good" }, + { "key": "good" }, + { "key": "fairly_good" }, + { "key": "not_good" }, + { "key": "not_at_all" } + ], + "pointsMap": { + "very_good": 0, + "good": 0, + "fairly_good": 0, + "not_good": 0, + "not_at_all": 0 + } + }, + { + "id": "q18", + "layout": "radio_question", + "question": "job_search_question", + "options": [ + { "key": "very_difficult" }, + { "key": "rather_difficult" }, + { "key": "neither_nor" }, + { "key": "rather_easy" }, + { "key": "very_easy" } + ], + "pointsMap": { + "very_difficult": 0, + "rather_difficult": 0, + "neither_nor": 0, + "rather_easy": 0, + "very_easy": 0 + } + }, + { + "id": "last_page", + "layout": "last_page", + "textKey": "finish_data_entry", + "question": "data_final_warning" + } + ] +} diff --git a/website/temp-assets/questionnaire_order.json b/website/temp-assets/questionnaire_order.json new file mode 100644 index 0000000..5f971e3 --- /dev/null +++ b/website/temp-assets/questionnaire_order.json @@ -0,0 +1,67 @@ +[ + { + "file": "questionnaire_1_demographic_information.json", + "showPoints": false, + "condition": { + "alwaysAvailable": true + } + }, + { + "file": "questionnaire_2_rhs.json", + "showPoints": true, + "condition": { + "alwaysAvailable": true + } + }, + { + "file": "questionnaire_3_integration_index.json", + "showPoints": true, + "condition": { + "alwaysAvailable": true + } + }, + { + "file": "questionnaire_4_consultation_results.json", + "showPoints": false, + "condition": { + "requiresCompleted": [ + "questionnaire_1_demographic_information", + "questionnaire_2_rhs", + "questionnaire_3_integration_index" + ], + "questionnaire": "questionnaire_1_demographic_information", + "questionId": "consent_instruction", + "operator": "==", + "value": "consent_signed" + } + }, + { + "file": "questionnaire_5_final_interview.json", + "showPoints": false, + "condition": { + "requiresCompleted": ["questionnaire_4_consultation_results"], + "questionnaire": "questionnaire_4_consultation_results", + "questionId": "consultation_decision", + "operator": "==", + "value": "yellow" + } + }, + { + "file": "questionnaire_6_follow_up_survey.json", + "showPoints": true, + "condition": { + "anyOf": [ + { + "requiresCompleted": ["questionnaire_5_final_interview"] + }, + { + "requiresCompleted": ["questionnaire_4_consultation_results"], + "questionnaire": "questionnaire_4_consultation_results", + "questionId": "consultation_decision", + "operator": "!=", + "value": "yellow" + } + ] + } + } +]