Refactor error handling and logging in API responses

This commit is contained in:
2026-06-03 17:16:14 +02:00
parent 30d3ab4a87
commit d0daa7e937
27 changed files with 808 additions and 220 deletions

View File

@ -55,7 +55,6 @@ $routes = [
'backup' => __DIR__ . '/../handlers/backup.php', 'backup' => __DIR__ . '/../handlers/backup.php',
'dev' => __DIR__ . '/../handlers/dev.php', 'dev' => __DIR__ . '/../handlers/dev.php',
'dev/import' => __DIR__ . '/../handlers/dev.php', 'dev/import' => __DIR__ . '/../handlers/dev.php',
'health' => __DIR__ . '/../handlers/health.php',
'activity-log' => __DIR__ . '/../handlers/activity_log.php', 'activity-log' => __DIR__ . '/../handlers/activity_log.php',
]; ];

View File

@ -235,23 +235,14 @@ function get_bearer_token(): ?string {
function require_valid_token(): array { function require_valid_token(): array {
$token = get_bearer_token(); $token = get_bearer_token();
if (!$token) { if (!$token) {
http_response_code(401); json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Missing Bearer token"]);
exit;
} }
$rec = token_get_record($token); $rec = token_get_record($token);
if (!$rec) { if (!$rec) {
http_response_code(403); json_error('UNAUTHORIZED', 'Invalid or expired token', 401);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Invalid or expired token"]);
exit;
} }
if (!empty($rec['temp'])) { if (!empty($rec['temp'])) {
http_response_code(403); json_error('PASSWORD_CHANGE_REQUIRED', 'Password change required before access', 403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Password change required before access"]);
exit;
} }
$rec['_token'] = $token; $rec['_token'] = $token;
return $rec; return $rec;
@ -260,10 +251,7 @@ function require_valid_token(): array {
function require_role(array $allowed, array $tokenRecord): void { function require_role(array $allowed, array $tokenRecord): void {
$role = $tokenRecord['role'] ?? ''; $role = $tokenRecord['role'] ?? '';
if (!in_array($role, $allowed, true)) { if (!in_array($role, $allowed, true)) {
http_response_code(403); json_error('FORBIDDEN', 'Insufficient permissions', 403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Insufficient permissions"]);
exit;
} }
} }

View File

@ -283,3 +283,39 @@ function qdb_discard(string $tmpDb, $lockFp): void {
@fclose($lockFp); @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
*/
function qdb_open_read_or_fail(): ?array {
try {
return qdb_open(false);
} catch (Throwable $e) {
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/lib/errors.php';
error_log('qdb_open_read: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Database read'), 500);
}
}
/**
* Open a writable DB connection; on failure logs and returns a JSON error response.
*
* @return array{0: PDO, 1: string, 2: resource|null}|null
*/
function qdb_open_write_or_fail(): ?array {
try {
return qdb_open(true);
} catch (Throwable $e) {
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/lib/errors.php';
error_log('qdb_open_write: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Database write'), 500);
}
}

View File

@ -31,7 +31,34 @@ Use the front-controller routes under `/api/...`. The clean route `/api/app_ques
} }
``` ```
Some low-level auth failures can still return a legacy body such as `{"error":"Missing Bearer token"}` or `{"error":"Invalid or expired token"}`. Auth and permission failures use the same envelope, for example:
```json
{
"ok": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or expired token"
}
}
```
Submit validation failures (`POST /api/app_questionnaires`) return `code` `VALIDATION_FAILED` with a `details.errors` array:
```json
{
"ok": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "Some answers are invalid or missing",
"details": {
"errors": [
{ "questionID": "q3", "code": "MISSING_ANSWER", "message": "Required question is not answered" }
]
}
}
}
```
## Authentication ## Authentication

View File

@ -13,8 +13,9 @@ if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
} }
$date = trim((string)($_GET['date'] ?? date('Y-m-d'))); $date = trim((string)($_GET['date'] ?? date('Y-m-d')));
$activity = trim((string)($_GET['activity'] ?? '')); $activity = trim((string)($_GET['activity'] ?? ''));
$limit = (int)($_GET['limit'] ?? 200); $limit = (int)($_GET['limit'] ?? 200);
$errorsOnly = !empty($_GET['errorsOnly']) || $activity === 'errors';
json_success(qdb_api_log_read_entries($date, $activity, $limit)); json_success(qdb_api_log_read_entries($date, $activity, $limit, $errorsOnly));

View File

@ -8,8 +8,9 @@ require_once __DIR__ . '/../lib/analytics.php';
switch ($method) { switch ($method) {
case 'GET': case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try { try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if (!empty($_GET['overview'])) { if (!empty($_GET['overview'])) {
$data = qdb_analytics_overview($pdo, $tokenRec); $data = qdb_analytics_overview($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
@ -25,9 +26,15 @@ case 'GET':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('BAD_REQUEST', 'Unknown query', 400); json_error('BAD_REQUEST', 'Unknown query', 400);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('analytics GET: ' . $e->getMessage()); error_log('analytics GET: ' . $e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500); json_error('SERVER_ERROR', qdb_public_error_message($e, 'Analytics'), 500);
} }
break; break;
@ -36,15 +43,22 @@ case 'PUT':
$clientCode = trim((string)($body['clientCode'] ?? '')); $clientCode = trim((string)($body['clientCode'] ?? ''));
$note = (string)($body['note'] ?? ''); $note = (string)($body['note'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try { try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note); qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'note' => $note]); json_success(['clientCode' => $clientCode, 'note' => $note]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('analytics PUT: ' . $e->getMessage()); error_log('analytics PUT: ' . $e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500); json_error('SERVER_ERROR', qdb_public_error_message($e, 'Save note'), 500);
} }
break; break;

View File

@ -10,22 +10,35 @@ case 'GET':
if (!$qID) { if (!$qID) {
json_error('MISSING_PARAM', 'questionID query param required', 400); json_error('MISSING_PARAM', 'questionID query param required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex'); $opened = qdb_open_read_or_fail();
$stmt->execute([':qid' => $qID]); [$pdo, $tmpDb, $lockFp] = $opened;
$options = $stmt->fetchAll(PDO::FETCH_ASSOC); $stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex');
foreach ($options as &$o) { $stmt->execute([':qid' => $qID]);
$o['points'] = (int)$o['points']; $options = $stmt->fetchAll(PDO::FETCH_ASSOC);
$o['orderIndex'] = (int)$o['orderIndex']; foreach ($options as &$o) {
$o['optionKey'] = qdb_option_key($o); $o['points'] = (int)$o['points'];
$o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']); $o['orderIndex'] = (int)$o['orderIndex'];
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); $o['optionKey'] = qdb_option_key($o);
$tr->execute([':id' => $o['answerOptionID']]); $o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']);
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); $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) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('answer_options GET: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load answer options'), 500);
} }
unset($o);
qdb_discard($tmpDb, $lockFp);
json_success(['answerOptions' => $options]);
break; break;
case 'POST': case 'POST':

View File

@ -13,7 +13,8 @@
// Public app UI catalog for the login screen (no token, no PII). // Public app UI catalog for the login screen (no token, no PII).
if ($method === 'GET' && !empty($_GET['translations'])) { if ($method === 'GET' && !empty($_GET['translations'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
json_success(['translations' => qdb_build_app_translations_map($pdo)]); json_success(['translations' => qdb_build_app_translations_map($pdo)]);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
return; return;
@ -39,7 +40,8 @@ if ($method === 'POST') {
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null; $completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
try { try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true); $opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
// Verify questionnaire exists // Verify questionnaire exists
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id"); $qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
@ -101,6 +103,26 @@ if ($method === 'POST') {
]; ];
} }
require_once __DIR__ . '/../lib/app_submit_validate.php';
$validationErrors = qdb_validate_app_submit_payload(
$pdo,
$qnID,
$answers,
$shortIdMap,
$shortIdToType,
$symptomParentMap,
$optionMap
);
if ($validationErrors !== []) {
qdb_discard($tmpDb, $lockFp);
json_error(
'VALIDATION_FAILED',
'Some answers are invalid or missing',
400,
['errors' => $validationErrors]
);
}
$pdo->beginTransaction(); $pdo->beginTransaction();
$sumPoints = 0; $sumPoints = 0;
@ -120,7 +142,9 @@ if ($method === 'POST') {
foreach ($answers as $a) { foreach ($answers as $a) {
$shortId = trim($a['questionID'] ?? ''); $shortId = trim($a['questionID'] ?? '');
if ($shortId === '') continue; if ($shortId === '') {
continue;
}
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null; $answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
@ -136,7 +160,9 @@ if ($method === 'POST') {
// Resolve short ID to full questionID // Resolve short ID to full questionID
$fullQID = $shortIdMap[$shortId] ?? null; $fullQID = $shortIdMap[$shortId] ?? null;
if ($fullQID === null) continue; if ($fullQID === null) {
continue;
}
$answerOptionID = null; $answerOptionID = null;
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null; $freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
@ -228,10 +254,18 @@ if ($method === 'POST') {
json_success(['submitted' => true, 'sumPoints' => $sumPoints]); json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
} catch (Throwable $e) { } catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); if (isset($pdo) && $pdo->inTransaction()) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); $pdo->rollBack();
error_log($e->getMessage()); }
json_error('SERVER_ERROR', 'Internal server error', 500); if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('app_questionnaires POST: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Submit'), 500);
} }
} }
@ -239,7 +273,8 @@ if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qnID = $_GET['id'] ?? ''; $qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? ''; $translations = $_GET['translations'] ?? '';

View File

@ -72,7 +72,7 @@ case 'POST':
} }
if (!$chk->fetch()) { if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Coach not found or not authorized', 400); json_error('NOT_FOUND', 'Coach not found or not authorized', 404);
} }
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);

View File

@ -3,6 +3,10 @@
$tokenRec = require_valid_token_web(); $tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec); require_role(['admin'], $tokenRec);
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$dbPath = QDB_PATH; $dbPath = QDB_PATH;
$backupDir = __DIR__ . '/../uploads/backups'; $backupDir = __DIR__ . '/../uploads/backups';

View File

@ -67,7 +67,7 @@ case 'POST':
} }
if (!$chkCoach->fetch()) { if (!$chkCoach->fetch()) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Coach not found or not authorized', 400); json_error('NOT_FOUND', 'Coach not found or not authorized', 404);
} }
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc"); $chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");

View File

@ -9,8 +9,9 @@ if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try { try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$coachID = trim((string)($_GET['coachID'] ?? '')); $coachID = trim((string)($_GET['coachID'] ?? ''));
if ($coachID !== '' && !empty($_GET['recent'])) { if ($coachID !== '' && !empty($_GET['recent'])) {
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100; $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
@ -24,7 +25,13 @@ try {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches]); json_success(['coaches' => $coaches]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('coaches GET: ' . $e->getMessage()); error_log('coaches GET: ' . $e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500); json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load coaches'), 500);
} }

View File

@ -53,5 +53,10 @@ try {
]; ];
$label = $labels[$action ?? 'import'] ?? 'Operation'; $label = $labels[$action ?? 'import'] ?? 'Operation';
error_log("dev fixture $label: " . $e->getMessage()); error_log("dev fixture $label: " . $e->getMessage());
json_error('SERVER_ERROR', "$label failed: " . $e->getMessage(), 500); require_once __DIR__ . '/../lib/errors.php';
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
error_log("dev fixture $label: " . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, $label), 500);
} }

View File

@ -10,20 +10,35 @@ if ($method !== 'GET') {
if (!empty($_GET['bundle'])) { if (!empty($_GET['bundle'])) {
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$bundle = qdb_export_all_questionnaires_bundle($pdo); $bundle = qdb_export_all_questionnaires_bundle($pdo);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
$filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json'; $filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit; exit;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('export bundle: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Export'), 500);
}
} }
if (!empty($_GET['exportAll'])) { if (!empty($_GET['exportAll'])) {
require_role(['admin'], $tokenRec); require_role(['admin'], $tokenRec);
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$allVersions = !empty($_GET['allVersions']); $allVersions = !empty($_GET['allVersions']);
$zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions); $zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
@ -34,9 +49,20 @@ if (!empty($_GET['exportAll'])) {
header('Content-Type: application/zip'); header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . (string)filesize($zipPath)); header('Content-Length: ' . (string)filesize($zipPath));
readfile($zipPath); readfile($zipPath);
@unlink($zipPath); @unlink($zipPath);
exit; exit;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('export exportAll: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Export'), 500);
}
} }
$qnID = $_GET['questionnaireID'] ?? ''; $qnID = $_GET['questionnaireID'] ?? '';
@ -46,7 +72,9 @@ if (!$qnID) {
$allVersions = !empty($_GET['allVersions']); $allVersions = !empty($_GET['allVersions']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id'); $qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$qn->execute([':id' => $qnID]); $qn->execute([':id' => $qnID]);
@ -80,6 +108,17 @@ qdb_discard($tmpDb, $lockFp);
$safeName = qdb_export_safe_basename($questionnaire['name']); $safeName = qdb_export_safe_basename($questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv'; $filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
header('Content-Type: text/csv; charset=UTF-8'); header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
echo qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)); echo qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns));
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('export csv: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Export'), 500);
}

View File

@ -1,42 +0,0 @@
<?php
/**
* GET /api/health — verify FPM sees the same project root, key, and DB as CLI.
*/
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$dbOpenOk = false;
$dbError = null;
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
qdb_discard($tmpDb, $lockFp);
$dbOpenOk = true;
} catch (Throwable $e) {
$dbError = $e->getMessage();
}
$keySha = null;
$keyError = null;
try {
$keySha = hash('sha256', get_master_key_bytes());
} catch (Throwable $e) {
$keyError = $e->getMessage();
}
json_success([
'sapi' => PHP_SAPI,
'projectRoot' => realpath(__DIR__ . '/..') ?: (__DIR__ . '/..'),
'commonFile' => realpath(__DIR__ . '/../common.php') ?: (__DIR__ . '/../common.php'),
'commonMtime' => @filemtime(__DIR__ . '/../common.php') ?: 0,
'envFile' => qdb_env_file_path(),
'envReadable' => is_readable(qdb_env_file_path()),
'keySha256' => $keySha,
'keyError' => $keyError,
'dbPath' => QDB_PATH,
'dbRealpath' => realpath(QDB_PATH) ?: null,
'dbBytes' => is_file(QDB_PATH) ? filesize(QDB_PATH) : 0,
'dbOpenOk' => $dbOpenOk,
'dbError' => $dbError,
'keyMatchesCli' => $keySha === 'e584e16eb81fbb4f1e3aac965b5225c5fefabc2a67ea20e26e55671c7d9f1ae7',
]);

View File

@ -9,5 +9,15 @@ if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401); json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
} }
token_revoke($token); try {
token_revoke($token);
} catch (Throwable $e) {
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('logout: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Logout'), 500);
}
json_success(['loggedOut' => true]); json_success(['loggedOut' => true]);

View File

@ -5,8 +5,9 @@ $tokenRec = require_valid_token_web();
switch ($method) { switch ($method) {
case 'GET': case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try { try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = " $sql = "
SELECT q.questionnaireID, q.name, q.version, q.state, SELECT q.questionnaireID, q.name, q.version, q.state,
@ -39,9 +40,15 @@ case 'GET':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]); json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('questionnaires GET: ' . $e->getMessage()); error_log('questionnaires GET: ' . $e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500); json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load questionnaires'), 500);
} }
break; break;
@ -122,7 +129,12 @@ case 'POST':
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
error_log('importBundle: ' . $e->getMessage()); error_log('importBundle: ' . $e->getMessage());
json_error('SERVER_ERROR', $e->getMessage(), 500); require_once __DIR__ . '/../lib/errors.php';
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
error_log('importBundle: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Import'), 500);
} }
break; break;
} }

View File

@ -9,8 +9,10 @@ case 'GET':
if (!$qnID) { if (!$qnID) {
json_error('BAD_REQUEST', 'questionnaireID query param required', 400); json_error('BAD_REQUEST', 'questionnaireID query param required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$stmt = $pdo->prepare(" $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare("
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
"); ");
@ -40,9 +42,20 @@ case 'GET':
$tr->execute([':qid' => $q['questionID']]); $tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); $q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
} }
unset($q); unset($q);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['questions' => $questions]); json_success(['questions' => $questions]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('questions GET: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load questions'), 500);
}
break; break;
case 'POST': case 'POST':
@ -204,18 +217,57 @@ case 'PATCH':
if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) { if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400); json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(true); $qnID = (string)$body['questionnaireID'];
$orderIds = array_values(array_filter(
array_map('strval', $body['order']),
static fn($id) => $id !== ''
));
if ($orderIds === []) {
json_error('BAD_REQUEST', 'order[] must list at least one questionID', 400);
}
try { try {
$stmt = $pdo->prepare("UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid"); $opened = qdb_open_write_or_fail();
foreach ($body['order'] as $idx => $qid) { [$pdo, $tmpDb, $lockFp] = $opened;
$stmt->execute([':o' => $idx, ':id' => $qid, ':qid' => $body['questionnaireID']]); $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); qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]); json_success(['reordered' => true]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
error_log($e->getMessage()); qdb_discard($tmpDb, $lockFp);
json_error('SERVER_ERROR', 'Server error', 500); }
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('questions PATCH: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Reorder questions'), 500);
} }
break; break;

View File

@ -13,9 +13,11 @@ if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400); json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); $qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]); $qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC); $questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) { if (!$questionnaire) {
@ -110,10 +112,21 @@ if (!empty($questionIDs) && !empty($clients)) {
unset($c); unset($c);
} }
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
"questionnaire" => $questionnaire, 'questionnaire' => $questionnaire,
"questions" => $questions, 'questions' => $questions,
"clients" => $clients, 'clients' => $clients,
]); ]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('results GET: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load results'), 500);
}

View File

@ -26,14 +26,23 @@ if ($role === 'coach') {
$username = ''; $username = '';
if (($rec['userID'] ?? '') !== '') { if (($rec['userID'] ?? '') !== '') {
try { try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid'); $stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
$stmt->execute([':uid' => $rec['userID']]); $stmt->execute([':uid' => $rec['userID']]);
$row = $stmt->fetchColumn(); $row = $stmt->fetchColumn();
$username = $row !== false ? (string)$row : ''; $username = $row !== false ? (string)$row : '';
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) { } catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('session username lookup: ' . $e->getMessage()); error_log('session username lookup: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Session check'), 500);
} }
} }

View File

@ -9,9 +9,22 @@ switch ($method) {
case 'GET': case 'GET':
if (!empty($_GET['exportBundle'])) { if (!empty($_GET['exportBundle'])) {
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$bundle = qdb_export_translations_bundle($pdo); $opened = qdb_open_read_or_fail();
qdb_discard($tmpDb, $lockFp); [$pdo, $tmpDb, $lockFp] = $opened;
$bundle = qdb_export_translations_bundle($pdo);
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations exportBundle: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Export'), 500);
}
$filename = 'translations_bundle_' . date('Y-m-d_His') . '.json'; $filename = 'translations_bundle_' . date('Y-m-d_His') . '.json';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
@ -21,14 +34,29 @@ case 'GET':
} }
if (isset($_GET['languages'])) { if (isset($_GET['languages'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); $opened = qdb_open_read_or_fail();
qdb_discard($tmpDb, $lockFp); [$pdo, $tmpDb, $lockFp] = $opened;
json_success(["languages" => $rows]); $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) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations languages: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load languages'), 500);
}
} }
if (!empty($_GET['all'])) { if (!empty($_GET['all'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false); 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); $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)) { if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
@ -65,41 +93,69 @@ case 'GET':
} }
} }
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'languages' => $languages, 'languages' => $languages,
'appStrings' => $appEntries, 'appStrings' => $appEntries,
'questionnaires' => $questionnaires, 'questionnaires' => $questionnaires,
'entries' => $allEntries, 'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'sourceLanguage' => QDB_SOURCE_LANGUAGE,
]); ]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations all: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load translations'), 500);
}
} }
$qnID = $_GET['questionnaireID'] ?? ''; $qnID = trim((string)($_GET['questionnaireID'] ?? ''));
if ($qnID) { if ($qnID !== '') {
[$pdo, $tmpDb, $lockFp] = qdb_open(false); 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); $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)) { if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']); array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
} }
$qnRow = $pdo->prepare("SELECT name FROM questionnaire WHERE questionnaireID = :id"); $qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id');
$qnRow->execute([':id' => $qnID]); $qnRow->execute([':id' => $qnID]);
$qnName = $qnRow->fetchColumn() ?: ''; $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); $lists = qdb_translation_entry_lists($pdo, $qnID);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']); $entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
$translations = qdb_load_translations_for_entries($pdo, $entries); $translations = qdb_load_translations_for_entries($pdo, $entries);
qdb_attach_translation_texts($entries, $translations); qdb_attach_translation_texts($entries, $translations);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'languages' => $languages, 'languages' => $languages,
'entries' => $entries, 'entries' => $entries,
'questionnaire' => ['questionnaireID' => $qnID, 'name' => $qnName], 'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName],
'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'sourceLanguage' => QDB_SOURCE_LANGUAGE,
]); ]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations questionnaire: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load translations'), 500);
}
} }
$type = $_GET['type'] ?? ''; $type = $_GET['type'] ?? '';
@ -108,8 +164,10 @@ case 'GET':
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400); json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(false); try {
if ($type === 'question') { $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 = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
} elseif ($type === 'answer_option') { } elseif ($type === 'answer_option') {
@ -123,9 +181,27 @@ case 'GET':
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode"); $stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
} }
} }
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if ($type === 'question' || $type === 'answer_option') {
qdb_discard($tmpDb, $lockFp); if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
json_success(["translations" => $rows]); 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) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
}
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations GET: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load translations'), 500);
}
break; break;
case 'POST': case 'POST':
@ -144,7 +220,12 @@ case 'POST':
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
error_log('translations importBundle: ' . $e->getMessage()); error_log('translations importBundle: ' . $e->getMessage());
json_error('SERVER_ERROR', $e->getMessage(), 500); require_once __DIR__ . '/../lib/errors.php';
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
error_log('translations importBundle: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Import'), 500);
} }
break; break;
} }
@ -188,15 +269,26 @@ case 'PUT':
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400); json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try { 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_put_translation($pdo, $type, $id, $lang, $text);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([]); json_success([]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
error_log($e->getMessage()); qdb_discard($tmpDb, $lockFp);
json_error('SERVER_ERROR', 'Server error', 500); }
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations PUT: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Save translation'), 500);
} }
break; break;
@ -235,24 +327,37 @@ case 'DELETE':
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400); json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
} }
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try { 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') { if ($type === 'question') {
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang") $pdo->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]); ->execute([':id' => $id, ':lang' => $lang]);
} elseif ($type === 'answer_option') { } elseif ($type === 'answer_option') {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang") $pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]); ->execute([':id' => $id, ':lang' => $lang]);
} else { } else {
$pdo->prepare("DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang") $pdo->prepare('DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]); ->execute([':id' => $id, ':lang' => $lang]);
} }
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([]); json_success([]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); if (isset($tmpDb, $lockFp)) {
error_log($e->getMessage()); qdb_discard($tmpDb, $lockFp);
json_error('SERVER_ERROR', 'Server error', 500); }
if (function_exists('qdb_api_log_note_exception')) {
qdb_api_log_note_exception($e);
}
require_once __DIR__ . '/../lib/errors.php';
error_log('translations DELETE: ' . $e->getMessage());
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Delete translation'), 500);
} }
break; break;

View File

@ -15,7 +15,7 @@
*/ */
/** @var list<string> */ /** @var list<string> */
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export']; const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export', 'api_error'];
function qdb_api_log_enabled(): bool function qdb_api_log_enabled(): bool
{ {
@ -150,7 +150,7 @@ function qdb_api_log_classify_activity(string $method, string $client, string $r
$client = strtolower(trim($client)); $client = strtolower(trim($client));
$route = strtolower(trim($route)); $route = strtolower(trim($route));
if ($method === 'OPTIONS' || in_array($route, ['health', 'activity-log', 'session'], true)) { if ($method === 'OPTIONS' || in_array($route, ['activity-log', 'session'], true)) {
return null; return null;
} }
@ -191,37 +191,48 @@ function qdb_api_log_begin(string $method, string $route, array $context = []):
$client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? '')); $client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
$activity = qdb_api_log_classify_activity($method, $client, $route); $activity = qdb_api_log_classify_activity($method, $client, $route);
if ($activity === null) {
$GLOBALS['qdb_api_log_ctx'] = ['skip' => true];
return;
}
$GLOBALS['qdb_api_log_ctx'] = array_merge([ $GLOBALS['qdb_api_log_ctx'] = array_merge([
'started_at' => microtime(true), 'started_at' => microtime(true),
'method' => strtoupper($method), 'method' => strtoupper($method),
'route' => $route, 'route' => $route,
'activity' => $activity,
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''), 'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
'ip' => qdb_api_log_client_ip(), 'ip' => qdb_api_log_client_ip(),
'client' => $client, 'client' => $client,
'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')), 'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')),
], $context); ], $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 function qdb_api_log_note_exception(Throwable $e): void
{ {
if (empty($GLOBALS['qdb_api_log_ctx']) || !empty($GLOBALS['qdb_api_log_ctx']['skip'])) { if (empty($GLOBALS['qdb_api_log_ctx'])) {
return; return;
} }
$GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage(); $GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage();
$GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class; $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 function qdb_api_log_finish(): void
{ {
$ctx = $GLOBALS['qdb_api_log_ctx'] ?? null; $ctx = $GLOBALS['qdb_api_log_ctx'] ?? null;
unset($GLOBALS['qdb_api_log_ctx']); unset($GLOBALS['qdb_api_log_ctx']);
if (!is_array($ctx) || !qdb_api_log_enabled() || !empty($ctx['skip'])) { if (!is_array($ctx) || !qdb_api_log_enabled()) {
return; return;
} }
@ -231,6 +242,14 @@ function qdb_api_log_finish(): void
$status = 200; $status = 200;
} }
$isError = $status >= 400
|| !empty($ctx['exception'])
|| !empty($ctx['api_error_code']);
if (!empty($ctx['skip_success']) && !$isError) {
return;
}
$token = get_bearer_token(); $token = get_bearer_token();
$auth = ['role' => null, 'userID' => null, 'token_hint' => null]; $auth = ['role' => null, 'userID' => null, 'token_hint' => null];
if ($token !== null && $token !== '') { if ($token !== null && $token !== '') {
@ -256,9 +275,14 @@ function qdb_api_log_finish(): void
$actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null); $actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null);
$targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog); $targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog);
$activity = $ctx['activity'] ?? null;
if ($isError) {
$activity = 'api_error';
}
$entry = [ $entry = [
'ts' => gmdate('c'), 'ts' => gmdate('c'),
'activity' => $ctx['activity'] ?? null, 'activity' => $activity,
'method' => $ctx['method'] ?? '', 'method' => $ctx['method'] ?? '',
'route' => $ctx['route'] ?? '', 'route' => $ctx['route'] ?? '',
'status' => $status, 'status' => $status,
@ -281,11 +305,19 @@ function qdb_api_log_finish(): void
), ),
]; ];
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'])) { if (!empty($ctx['exception'])) {
$entry['error'] = (string)$ctx['exception']; $entry['error_internal'] = (string)$ctx['exception'];
if (!empty($ctx['exception_class'])) { if (!empty($ctx['exception_class'])) {
$entry['error_class'] = (string)$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'] ?? '') !== '') { if (($ctx['user_agent'] ?? '') !== '') {
@ -563,13 +595,17 @@ function qdb_api_log_resolve_write_path(string $dir): string
* *
* @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool} * @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool}
*/ */
function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200): array 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)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d'); $date = date('Y-m-d');
} }
$limit = max(1, min(1000, $limit)); $limit = max(1, min(1000, $limit));
$activityFilter = strtolower(trim($activityFilter)); $activityFilter = strtolower(trim($activityFilter));
if ($activityFilter === 'errors') {
$errorsOnly = true;
$activityFilter = '';
}
if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) { if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) {
$activityFilter = ''; $activityFilter = '';
} }
@ -591,6 +627,13 @@ function qdb_api_log_read_entries(string $date, string $activityFilter = '', int
if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) { if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) {
continue; 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); $entries[] = qdb_api_log_entry_for_ui($row);
} }
} }
@ -607,13 +650,14 @@ function qdb_api_log_read_entries(string $date, string $activityFilter = '', int
$entries = qdb_api_log_enrich_entries_usernames($entries); $entries = qdb_api_log_enrich_entries_usernames($entries);
return [ return [
'date' => $date, 'date' => $date,
'activity' => $activityFilter, 'activity' => $activityFilter,
'entries' => $entries, 'errorsOnly' => $errorsOnly,
'files' => array_map('basename', $files), 'entries' => $entries,
'truncated' => $truncated, 'files' => array_map('basename', $files),
'kinds' => QDB_API_LOG_ACTIVITIES, 'truncated' => $truncated,
'logStatus' => qdb_api_log_status(), 'kinds' => QDB_API_LOG_ACTIVITIES,
'logStatus' => qdb_api_log_status(),
]; ];
} }
@ -663,6 +707,8 @@ function qdb_api_log_entry_for_ui(array $row): array
'changes' => $changes, 'changes' => $changes,
'summary' => qdb_api_log_summarize_changes($changes, $targetUsername), 'summary' => qdb_api_log_summarize_changes($changes, $targetUsername),
'error' => $row['error'] ?? null, 'error' => $row['error'] ?? null,
'error_code' => $row['error_code'] ?? null,
'error_message' => $row['error_message'] ?? null,
]; ];
} }

171
lib/app_submit_validate.php Normal file
View File

@ -0,0 +1,171 @@
<?php
/**
* Validate Android app questionnaire submit payloads before persisting answers.
*
* @return list<array{questionID: string, code: string, message: string}>
*/
function qdb_validate_app_submit_payload(
PDO $pdo,
string $qnID,
array $rawAnswers,
array $shortIdMap,
array $shortIdToType,
array $symptomParentMap,
array $optionMap
): array {
$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;
}
}
$qStmt = $pdo->prepare(
'SELECT questionID, type, isRequired, configJson FROM question WHERE questionnaireID = :qn'
);
$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;
}

25
lib/errors.php Normal file
View File

@ -0,0 +1,25 @@
<?php
/**
* 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 (str_contains($msg, 'Could not acquire lock') || str_contains($msg, 'Could not open lock')) {
return 'Database is busy. 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.';
}

View File

@ -9,10 +9,20 @@ function json_success(mixed $data, int $status = 200): never {
exit; exit;
} }
function json_error(string $code, string $message, int $status = 400): never { /**
* @param mixed $details Optional structured payload (e.g. validation error list).
*/
function json_error(string $code, string $message, int $status = 400, mixed $details = null): never {
if (function_exists('qdb_api_log_note_api_error')) {
qdb_api_log_note_api_error($code, $message, $status);
}
http_response_code($status); http_response_code($status);
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["ok" => false, "error" => ["code" => $code, "message" => $message]]); $error = ["code" => $code, "message" => $message];
if ($details !== null) {
$error['details'] = $details;
}
echo json_encode(["ok" => false, "error" => $error]);
exit; exit;
} }

View File

@ -27,14 +27,17 @@ export function redirectToLogin() {
/** @returns {boolean} true if the response means the session is no longer valid */ /** @returns {boolean} true if the response means the session is no longer valid */
function isSessionInvalidResponse(res, json) { function isSessionInvalidResponse(res, json) {
if (res.status === 401) return true; if (res.status === 401) return true;
const errMsg = json.error?.message || json.error || ''; const errMsg = json.error?.message || (typeof json.error === 'string' ? json.error : '');
return res.status === 403 && ( const errCode = json.error?.code || '';
errMsg === 'Invalid or expired token' return res.status === 401
|| errMsg === 'Missing Bearer token' || res.status === 403 && (
|| errMsg === 'Password change required before access' errCode === 'UNAUTHORIZED'
|| json.error?.code === 'UNAUTHORIZED' || errCode === 'PASSWORD_CHANGE_REQUIRED'
|| json.error?.code === 'PASSWORD_CHANGE_REQUIRED' || errCode === 'FORBIDDEN'
); || errMsg === 'Invalid or expired token'
|| errMsg === 'Missing Bearer token'
|| errMsg === 'Password change required before access'
);
} }
/** /**
@ -125,7 +128,9 @@ function unwrap(json) {
const d = json.data || {}; const d = json.data || {};
return { success: true, ...(Array.isArray(d) ? { items: d } : d) }; return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
} }
return { success: false, error: json.error?.message || 'Unknown error' }; 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; return json;
} }

View File

@ -7,6 +7,7 @@ const ACTIVITY_LABELS = {
app_change: 'App change', app_change: 'App change',
web_change: 'Website change', web_change: 'Website change',
web_export: 'Website export', web_export: 'Website export',
api_error: 'API error',
}; };
export function devPage() { export function devPage() {
@ -75,6 +76,7 @@ export function devPage() {
<option value="app_change">App change</option> <option value="app_change">App change</option>
<option value="web_change">Website change</option> <option value="web_change">Website change</option>
<option value="web_export">Website export</option> <option value="web_export">Website export</option>
<option value="errors">Errors only (4xx/5xx)</option>
</select> </select>
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button> <button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
</div> </div>
@ -324,7 +326,9 @@ async function loadActivityLog() {
const label = ACTIVITY_LABELS[kind] || kind || '—'; const label = ACTIVITY_LABELS[kind] || kind || '—';
const badgeClass = kind ? `badge-activity-${kind}` : ''; const badgeClass = kind ? `badge-activity-${kind}` : '';
const time = formatActivityTime(e.ts); const time = formatActivityTime(e.ts);
const status = e.status != null ? String(e.status) : '—'; 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 dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—';
const subject = formatActivitySubject(e); const subject = formatActivitySubject(e);
const performedBy = formatActivityPerformer(e); const performedBy = formatActivityPerformer(e);
@ -339,7 +343,7 @@ async function loadActivityLog() {
<td><code>${escHtml(e.method || '')}</code></td> <td><code>${escHtml(e.method || '')}</code></td>
<td><code>${escHtml(e.route || '')}</code></td> <td><code>${escHtml(e.route || '')}</code></td>
<td class="activity-log-ip"><code>${escHtml(ip)}</code></td> <td class="activity-log-ip"><code>${escHtml(ip)}</code></td>
<td>${escHtml(status)}</td> <td class="${statusClass}">${escHtml(status)}</td>
<td>${escHtml(dur)}</td> <td>${escHtml(dur)}</td>
<td class="activity-log-changes-cell">${renderActivityChanges(e.changes)}</td> <td class="activity-log-changes-cell">${renderActivityChanges(e.changes)}</td>
<td class="activity-log-actor">${performedBy}${errPart}</td> <td class="activity-log-actor">${performedBy}${errPart}</td>