Refactor error handling and logging in API responses
This commit is contained in:
@ -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',
|
||||
];
|
||||
|
||||
|
||||
20
common.php
20
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
db_init.php
36
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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'] ?? '';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
@ -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]);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user