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',
'dev' => __DIR__ . '/../handlers/dev.php',
'dev/import' => __DIR__ . '/../handlers/dev.php',
'health' => __DIR__ . '/../handlers/health.php',
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
];

View File

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

View File

@ -283,3 +283,39 @@ function qdb_discard(string $tmpDb, $lockFp): void {
@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

View File

@ -16,5 +16,6 @@ if ($method !== 'GET') {
$date = trim((string)($_GET['date'] ?? date('Y-m-d')));
$activity = trim((string)($_GET['activity'] ?? ''));
$limit = (int)($_GET['limit'] ?? 200);
$errorsOnly = !empty($_GET['errorsOnly']) || $activity === 'errors';
json_success(qdb_api_log_read_entries($date, $activity, $limit));
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) {
case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if (!empty($_GET['overview'])) {
$data = qdb_analytics_overview($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
@ -25,9 +26,15 @@ case 'GET':
qdb_discard($tmpDb, $lockFp);
json_error('BAD_REQUEST', 'Unknown query', 400);
} 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('analytics GET: ' . $e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Analytics'), 500);
}
break;
@ -36,15 +43,22 @@ case 'PUT':
$clientCode = trim((string)($body['clientCode'] ?? ''));
$note = (string)($body['note'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
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) {
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());
json_error('SERVER_ERROR', 'Server error', 500);
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Save note'), 500);
}
break;

View File

@ -10,7 +10,9 @@ case 'GET':
if (!$qID) {
json_error('MISSING_PARAM', 'questionID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex');
$stmt->execute([':qid' => $qID]);
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
@ -26,6 +28,17 @@ case 'GET':
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);
}
break;
case 'POST':

View File

@ -13,7 +13,8 @@
// Public app UI catalog for the login screen (no token, no PII).
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)]);
qdb_discard($tmpDb, $lockFp);
return;
@ -39,7 +40,8 @@ if ($method === 'POST') {
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
// Verify questionnaire exists
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
@ -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();
$sumPoints = 0;
@ -120,7 +142,9 @@ if ($method === 'POST') {
foreach ($answers as $a) {
$shortId = trim($a['questionID'] ?? '');
if ($shortId === '') continue;
if ($shortId === '') {
continue;
}
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
@ -136,7 +160,9 @@ if ($method === 'POST') {
// Resolve short ID to full questionID
$fullQID = $shortIdMap[$shortId] ?? null;
if ($fullQID === null) continue;
if ($fullQID === null) {
continue;
}
$answerOptionID = null;
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
@ -228,10 +254,18 @@ if ($method === 'POST') {
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Internal server error', 500);
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
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);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';

View File

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

View File

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

View File

@ -67,7 +67,7 @@ case 'POST':
}
if (!$chkCoach->fetch()) {
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");

View File

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

View File

@ -53,5 +53,10 @@ try {
];
$label = $labels[$action ?? 'import'] ?? 'Operation';
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,7 +10,9 @@ if ($method !== 'GET') {
if (!empty($_GET['bundle'])) {
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);
qdb_discard($tmpDb, $lockFp);
@ -19,11 +21,24 @@ if (!empty($_GET['bundle'])) {
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
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'])) {
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']);
$zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions);
qdb_discard($tmpDb, $lockFp);
@ -37,6 +52,17 @@ if (!empty($_GET['exportAll'])) {
readfile($zipPath);
@unlink($zipPath);
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'] ?? '';
@ -46,7 +72,9 @@ if (!$qnID) {
$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->execute([':id' => $qnID]);
@ -83,3 +111,14 @@ $filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
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);
}
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]);

View File

@ -5,8 +5,9 @@ $tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT q.questionnaireID, q.name, q.version, q.state,
@ -39,9 +40,15 @@ case 'GET':
qdb_discard($tmpDb, $lockFp);
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
} 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('questionnaires GET: ' . $e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load questionnaires'), 500);
}
break;
@ -122,7 +129,12 @@ case 'POST':
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
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;
}

View File

@ -9,7 +9,9 @@ case 'GET':
if (!$qnID) {
json_error('BAD_REQUEST', 'questionnaireID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare("
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
@ -43,6 +45,17 @@ case 'GET':
unset($q);
qdb_discard($tmpDb, $lockFp);
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;
case 'POST':
@ -204,18 +217,57 @@ case 'PATCH':
if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$qnID = (string)$body['questionnaireID'];
$orderIds = array_values(array_filter(
array_map('strval', $body['order']),
static fn($id) => $id !== ''
));
if ($orderIds === []) {
json_error('BAD_REQUEST', 'order[] must list at least one questionID', 400);
}
try {
$stmt = $pdo->prepare("UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid");
foreach ($body['order'] as $idx => $qid) {
$stmt->execute([':o' => $idx, ':id' => $qid, ':qid' => $body['questionnaireID']]);
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
$chk->execute([':id' => $qnID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$ph = implode(',', array_fill(0, count($orderIds), '?'));
$stmt = $pdo->prepare(
"SELECT questionID FROM question WHERE questionnaireID = ? AND questionID IN ($ph)"
);
$stmt->execute(array_merge([$qnID], $orderIds));
$found = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (count($found) !== count($orderIds)) {
$missing = array_values(array_diff($orderIds, $found));
qdb_discard($tmpDb, $lockFp);
json_error(
'INVALID_FIELD',
'order[] contains questionIDs not in this questionnaire',
400,
['missingQuestionIDs' => $missing]
);
}
$upd = $pdo->prepare(
'UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid'
);
foreach ($orderIds as $idx => $qid) {
$upd->execute([':o' => $idx, ':id' => $qid, ':qid' => $qnID]);
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
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;

View File

@ -13,7 +13,9 @@ if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
@ -113,7 +115,18 @@ if (!empty($questionIDs) && !empty($clients)) {
qdb_discard($tmpDb, $lockFp);
json_success([
"questionnaire" => $questionnaire,
"questions" => $questions,
"clients" => $clients,
'questionnaire' => $questionnaire,
'questions' => $questions,
'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 = '';
if (($rec['userID'] ?? '') !== '') {
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->execute([':uid' => $rec['userID']]);
$row = $stmt->fetchColumn();
$username = $row !== false ? (string)$row : '';
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('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':
if (!empty($_GET['exportBundle'])) {
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_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';
header('Content-Type: application/json; charset=UTF-8');
@ -21,14 +34,29 @@ case 'GET':
}
if (isset($_GET['languages'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
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]);
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'])) {
[$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);
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
@ -73,20 +101,37 @@ case 'GET':
'entries' => $allEntries,
'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'] ?? '';
if ($qnID) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$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 = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id');
$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);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
@ -97,9 +142,20 @@ case 'GET':
json_success([
'languages' => $languages,
'entries' => $entries,
'questionnaire' => ['questionnaireID' => $qnID, 'name' => $qnName],
'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName],
'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'] ?? '';
@ -108,7 +164,9 @@ case 'GET':
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if ($type === 'question') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
$stmt->execute([':id' => $id]);
@ -123,9 +181,27 @@ case 'GET':
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
}
}
if ($type === 'question' || $type === 'answer_option') {
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Translation entity not found', 404);
}
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(["translations" => $rows]);
json_success(['translations' => $rows]);
} catch (Throwable $e) {
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;
case 'POST':
@ -144,7 +220,12 @@ case 'POST':
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
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;
}
@ -188,15 +269,26 @@ case 'PUT':
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Translation entity not found', 404);
}
qdb_put_translation($pdo, $type, $id, $lang, $text);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
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;
@ -235,24 +327,37 @@ case 'DELETE':
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if ($type === 'question' || $type === 'answer_option') {
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Translation entity not found', 404);
}
}
if ($type === 'question') {
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang")
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]);
} elseif ($type === 'answer_option') {
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang")
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]);
} else {
$pdo->prepare("DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang")
$pdo->prepare('DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]);
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
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;

View File

@ -15,7 +15,7 @@
*/
/** @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
{
@ -150,7 +150,7 @@ function qdb_api_log_classify_activity(string $method, string $client, string $r
$client = strtolower(trim($client));
$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;
}
@ -191,37 +191,48 @@ function qdb_api_log_begin(string $method, string $route, array $context = []):
$client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
$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([
'started_at' => microtime(true),
'method' => strtoupper($method),
'route' => $route,
'activity' => $activity,
'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']) || !empty($GLOBALS['qdb_api_log_ctx']['skip'])) {
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() || !empty($ctx['skip'])) {
if (!is_array($ctx) || !qdb_api_log_enabled()) {
return;
}
@ -231,6 +242,14 @@ function qdb_api_log_finish(): void
$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 !== '') {
@ -256,9 +275,14 @@ function qdb_api_log_finish(): void
$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' => $ctx['activity'] ?? null,
'activity' => $activity,
'method' => $ctx['method'] ?? '',
'route' => $ctx['route'] ?? '',
'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'])) {
$entry['error'] = (string)$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'] ?? '') !== '') {
@ -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}
*/
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)) {
$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 = '';
}
@ -591,6 +627,13 @@ function qdb_api_log_read_entries(string $date, string $activityFilter = '', int
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);
}
}
@ -609,6 +652,7 @@ function qdb_api_log_read_entries(string $date, string $activityFilter = '', int
return [
'date' => $date,
'activity' => $activityFilter,
'errorsOnly' => $errorsOnly,
'entries' => $entries,
'files' => array_map('basename', $files),
'truncated' => $truncated,
@ -663,6 +707,8 @@ function qdb_api_log_entry_for_ui(array $row): array
'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,
];
}

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

View File

@ -27,13 +27,16 @@ export function redirectToLogin() {
/** @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 || json.error || '';
return res.status === 403 && (
errMsg === 'Invalid or expired token'
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'
|| json.error?.code === 'UNAUTHORIZED'
|| json.error?.code === 'PASSWORD_CHANGE_REQUIRED'
);
}
@ -125,7 +128,9 @@ function unwrap(json) {
const d = json.data || {};
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;
}

View File

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