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

@ -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);
}
}
@ -607,13 +650,14 @@ function qdb_api_log_read_entries(string $date, string $activityFilter = '', int
$entries = qdb_api_log_enrich_entries_usernames($entries);
return [
'date' => $date,
'activity' => $activityFilter,
'entries' => $entries,
'files' => array_map('basename', $files),
'truncated' => $truncated,
'kinds' => QDB_API_LOG_ACTIVITIES,
'logStatus' => qdb_api_log_status(),
'date' => $date,
'activity' => $activityFilter,
'errorsOnly' => $errorsOnly,
'entries' => $entries,
'files' => array_map('basename', $files),
'truncated' => $truncated,
'kinds' => QDB_API_LOG_ACTIVITIES,
'logStatus' => qdb_api_log_status(),
];
}
@ -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;
}