Refactor error handling and logging in API responses
This commit is contained in:
@ -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
171
lib/app_submit_validate.php
Normal 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
25
lib/errors.php
Normal 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.';
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user