diff --git a/api/index.php b/api/index.php index 27c6af5..62029d3 100644 --- a/api/index.php +++ b/api/index.php @@ -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', ]; diff --git a/common.php b/common.php index 09d8395..7657791 100644 --- a/common.php +++ b/common.php @@ -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); } } diff --git a/db_init.php b/db_init.php index c3ab68e..7730ab9 100644 --- a/db_init.php +++ b/db_init.php @@ -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); + } +} diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 2b5ef31..b19d7ec 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -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 diff --git a/handlers/activity_log.php b/handlers/activity_log.php index d86c78b..8288664 100644 --- a/handlers/activity_log.php +++ b/handlers/activity_log.php @@ -13,8 +13,9 @@ if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } -$date = trim((string)($_GET['date'] ?? date('Y-m-d'))); -$activity = trim((string)($_GET['activity'] ?? '')); -$limit = (int)($_GET['limit'] ?? 200); +$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)); diff --git a/handlers/analytics.php b/handlers/analytics.php index 2d10b23..267290d 100644 --- a/handlers/analytics.php +++ b/handlers/analytics.php @@ -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) { - 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()); - 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) { - 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()); - json_error('SERVER_ERROR', 'Server error', 500); + json_error('SERVER_ERROR', qdb_public_error_message($e, 'Save note'), 500); } break; diff --git a/handlers/answer_options.php b/handlers/answer_options.php index d823409..8befcdf 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -10,22 +10,35 @@ case 'GET': if (!$qID) { json_error('MISSING_PARAM', 'questionID query param required', 400); } - [$pdo, $tmpDb, $lockFp] = qdb_open(false); - $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); - foreach ($options as &$o) { - $o['points'] = (int)$o['points']; - $o['orderIndex'] = (int)$o['orderIndex']; - $o['optionKey'] = qdb_option_key($o); - $o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']); - $tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); - $tr->execute([':id' => $o['answerOptionID']]); - $o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); + 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); + foreach ($options as &$o) { + $o['points'] = (int)$o['points']; + $o['orderIndex'] = (int)$o['orderIndex']; + $o['optionKey'] = qdb_option_key($o); + $o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']); + $tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); + $tr->execute([':id' => $o['answerOptionID']]); + $o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); + } + unset($o); + qdb_discard($tmpDb, $lockFp); + json_success(['answerOptions' => $options]); + } catch (Throwable $e) { + 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; case 'POST': diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index b1f1c2a..dac8c02 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -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'] ?? ''; diff --git a/handlers/assignments.php b/handlers/assignments.php index 5ccc6f8..4846356 100644 --- a/handlers/assignments.php +++ b/handlers/assignments.php @@ -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); diff --git a/handlers/backup.php b/handlers/backup.php index ddaa1a7..cff1050 100644 --- a/handlers/backup.php +++ b/handlers/backup.php @@ -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'; diff --git a/handlers/clients.php b/handlers/clients.php index 5c3f492..96dfc07 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -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"); diff --git a/handlers/coaches.php b/handlers/coaches.php index fa72e46..3f703e9 100644 --- a/handlers/coaches.php +++ b/handlers/coaches.php @@ -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) { - 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()); - json_error('SERVER_ERROR', 'Server error', 500); + json_error('SERVER_ERROR', qdb_public_error_message($e, 'Load coaches'), 500); } diff --git a/handlers/dev.php b/handlers/dev.php index 3478dcd..dbf8ddd 100644 --- a/handlers/dev.php +++ b/handlers/dev.php @@ -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); } diff --git a/handlers/export.php b/handlers/export.php index 1512a37..2c0f749 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -10,20 +10,35 @@ 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); $filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json'; header('Content-Type: application/json; charset=UTF-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); - echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); - exit; + 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); @@ -34,9 +49,20 @@ if (!empty($_GET['exportAll'])) { header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . (string)filesize($zipPath)); - readfile($zipPath); - @unlink($zipPath); - exit; + 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]); @@ -80,6 +108,17 @@ qdb_discard($tmpDb, $lockFp); $safeName = qdb_export_safe_basename($questionnaire['name']); $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)); + 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); +} diff --git a/handlers/health.php b/handlers/health.php deleted file mode 100644 index a840cb0..0000000 --- a/handlers/health.php +++ /dev/null @@ -1,42 +0,0 @@ -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', -]); diff --git a/handlers/logout.php b/handlers/logout.php index dbd8611..3990214 100644 --- a/handlers/logout.php +++ b/handlers/logout.php @@ -9,5 +9,15 @@ if (!$token) { 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]); diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 41c3c68..6d718ad 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -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) { - 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()); - 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; } diff --git a/handlers/questions.php b/handlers/questions.php index ee22c05..f38b7a0 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -9,8 +9,10 @@ case 'GET': if (!$qnID) { json_error('BAD_REQUEST', 'questionnaireID query param required', 400); } - [$pdo, $tmpDb, $lockFp] = qdb_open(false); - $stmt = $pdo->prepare(" + 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 "); @@ -40,9 +42,20 @@ case 'GET': $tr->execute([':qid' => $q['questionID']]); $q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); } - unset($q); - qdb_discard($tmpDb, $lockFp); - json_success(['questions' => $questions]); + 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) { - qdb_discard($tmpDb, $lockFp); - error_log($e->getMessage()); - json_error('SERVER_ERROR', '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('questions PATCH: ' . $e->getMessage()); + json_error('SERVER_ERROR', qdb_public_error_message($e, 'Reorder questions'), 500); } break; diff --git a/handlers/results.php b/handlers/results.php index c50943f..3de8f5e 100644 --- a/handlers/results.php +++ b/handlers/results.php @@ -13,9 +13,11 @@ 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 = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); $qn->execute([':id' => $qnID]); $questionnaire = $qn->fetch(PDO::FETCH_ASSOC); if (!$questionnaire) { @@ -110,10 +112,21 @@ if (!empty($questionIDs) && !empty($clients)) { unset($c); } -qdb_discard($tmpDb, $lockFp); + qdb_discard($tmpDb, $lockFp); -json_success([ - "questionnaire" => $questionnaire, - "questions" => $questions, - "clients" => $clients, -]); + json_success([ + '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); +} diff --git a/handlers/session.php b/handlers/session.php index 8aceb9a..7eebaad 100644 --- a/handlers/session.php +++ b/handlers/session.php @@ -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); } } diff --git a/handlers/translations.php b/handlers/translations.php index cb69047..b9b3320 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -9,9 +9,22 @@ switch ($method) { case 'GET': if (!empty($_GET['exportBundle'])) { require_role(['admin', 'supervisor'], $tokenRec); - [$pdo, $tmpDb, $lockFp] = qdb_open(false); - $bundle = qdb_export_translations_bundle($pdo); - qdb_discard($tmpDb, $lockFp); + 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); - $rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); - qdb_discard($tmpDb, $lockFp); - json_success(["languages" => $rows]); + try { + $opened = qdb_open_read_or_fail(); + [$pdo, $tmpDb, $lockFp] = $opened; + $rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); + qdb_discard($tmpDb, $lockFp); + json_success(['languages' => $rows]); + } catch (Throwable $e) { + 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)) { @@ -65,41 +93,69 @@ case 'GET': } } - qdb_discard($tmpDb, $lockFp); - json_success([ - 'languages' => $languages, - 'appStrings' => $appEntries, - 'questionnaires' => $questionnaires, - 'entries' => $allEntries, - 'sourceLanguage' => QDB_SOURCE_LANGUAGE, - ]); + qdb_discard($tmpDb, $lockFp); + json_success([ + 'languages' => $languages, + 'appStrings' => $appEntries, + 'questionnaires' => $questionnaires, + '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']); - } + $languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); + if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) { + array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']); + } - $qnRow = $pdo->prepare("SELECT name FROM questionnaire WHERE questionnaireID = :id"); - $qnRow->execute([':id' => $qnID]); - $qnName = $qnRow->fetchColumn() ?: ''; + $qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id'); + $qnRow->execute([':id' => $qnID]); + $qnName = $qnRow->fetchColumn(); + if ($qnName === false) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Questionnaire not found', 404); + } - $lists = qdb_translation_entry_lists($pdo, $qnID); + $lists = qdb_translation_entry_lists($pdo, $qnID); $entries = array_merge($lists['stringEntries'], $lists['contentEntries']); $translations = qdb_load_translations_for_entries($pdo, $entries); qdb_attach_translation_texts($entries, $translations); - qdb_discard($tmpDb, $lockFp); - json_success([ - 'languages' => $languages, - 'entries' => $entries, - 'questionnaire' => ['questionnaireID' => $qnID, 'name' => $qnName], - 'sourceLanguage' => QDB_SOURCE_LANGUAGE, - ]); + qdb_discard($tmpDb, $lockFp); + json_success([ + 'languages' => $languages, + 'entries' => $entries, + '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,8 +164,10 @@ case 'GET': json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400); } - [$pdo, $tmpDb, $lockFp] = qdb_open(false); - if ($type === 'question') { + try { + $opened = qdb_open_read_or_fail(); + [$pdo, $tmpDb, $lockFp] = $opened; + if ($type === 'question') { $stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id"); $stmt->execute([':id' => $id]); } elseif ($type === 'answer_option') { @@ -123,9 +181,27 @@ case 'GET': $stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode"); } } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - qdb_discard($tmpDb, $lockFp); - json_success(["translations" => $rows]); + if ($type === 'question' || $type === 'answer_option') { + if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Translation entity not found', 404); + } + } + + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + qdb_discard($tmpDb, $lockFp); + json_success(['translations' => $rows]); + } catch (Throwable $e) { + 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) { - qdb_discard($tmpDb, $lockFp); - error_log($e->getMessage()); - json_error('SERVER_ERROR', '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('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) { - qdb_discard($tmpDb, $lockFp); - error_log($e->getMessage()); - json_error('SERVER_ERROR', '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('translations DELETE: ' . $e->getMessage()); + json_error('SERVER_ERROR', qdb_public_error_message($e, 'Delete translation'), 500); } break; diff --git a/lib/api_log.php b/lib/api_log.php index ce8b22a..2272b6c 100644 --- a/lib/api_log.php +++ b/lib/api_log.php @@ -15,7 +15,7 @@ */ /** @var list */ -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>, files: list, 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, ]; } diff --git a/lib/app_submit_validate.php b/lib/app_submit_validate.php new file mode 100644 index 0000000..8a4b3e3 --- /dev/null +++ b/lib/app_submit_validate.php @@ -0,0 +1,171 @@ + + */ +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; +} diff --git a/lib/errors.php b/lib/errors.php new file mode 100644 index 0000000..81f03fd --- /dev/null +++ b/lib/errors.php @@ -0,0 +1,25 @@ +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.'; +} diff --git a/lib/response.php b/lib/response.php index 3476562..c1988ff 100644 --- a/lib/response.php +++ b/lib/response.php @@ -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; } diff --git a/website/js/api.js b/website/js/api.js index 93fa0c9..38be401 100644 --- a/website/js/api.js +++ b/website/js/api.js @@ -27,14 +27,17 @@ 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' - || errMsg === 'Missing Bearer token' - || errMsg === 'Password change required before access' - || json.error?.code === 'UNAUTHORIZED' - || json.error?.code === 'PASSWORD_CHANGE_REQUIRED' - ); + 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' + ); } /** @@ -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; } diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index a2625d2..be67724 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -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() { + @@ -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() { ${escHtml(e.method || '')} ${escHtml(e.route || '')} ${escHtml(ip)} - ${escHtml(status)} + ${escHtml(status)} ${escHtml(dur)} ${renderActivityChanges(e.changes)} ${performedBy}${errPart}