*/ const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export', 'api_error']; function qdb_api_log_enabled(): bool { $v = qdb_env_get('QDB_API_LOG'); if ($v === null || $v === '') { return true; } $v = strtolower(trim($v)); return !in_array($v, ['0', 'false', 'no', 'off'], true); } function qdb_api_log_dir(): string { static $resolved = null; if ($resolved !== null) { return $resolved; } $custom = qdb_env_get('QDB_API_LOG_DIR'); if ($custom !== null && $custom !== '') { $resolved = rtrim($custom, '/\\'); return $resolved; } $root = dirname(__DIR__); foreach ([$root . '/uploads/logs/api', $root . '/logs/api'] as $candidate) { if (qdb_api_log_dir_is_usable($candidate)) { $resolved = $candidate; return $resolved; } } $resolved = $root . '/uploads/logs/api'; return $resolved; } function qdb_api_log_dir_is_usable(string $dir): bool { if (is_dir($dir)) { return is_writable($dir); } $parent = dirname($dir); if (is_dir($parent) && is_writable($parent)) { return true; } if (@mkdir($dir, 0775, true)) { return is_writable($dir); } return is_dir($dir) && is_writable($dir); } /** * Diagnostics for admin UI (permissions, path, enabled flag). * * @return array */ function qdb_api_log_status(): array { $dir = qdb_api_log_dir(); $enabled = qdb_api_log_enabled(); $exists = is_dir($dir); $writable = false; $probeError = null; if ($enabled) { if (!$exists) { if (!@mkdir($dir, 0775, true)) { $probeError = 'Cannot create directory: ' . $dir; } $exists = is_dir($dir); } if ($exists && !is_writable($dir)) { $probeError = 'Directory exists but is not writable by PHP: ' . $dir; } elseif ($exists) { $probe = $dir . '/.write_probe'; if (@file_put_contents($probe, (string)time() . "\n", LOCK_EX) === false) { $probeError = 'Cannot write files in: ' . $dir; } else { $writable = true; @unlink($probe); } } } $phpUser = null; if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) { $info = posix_getpwuid(posix_geteuid()); if (is_array($info) && !empty($info['name'])) { $phpUser = (string)$info['name']; } } return [ 'enabled' => $enabled, 'dir' => $dir, 'exists' => $exists, 'writable' => $writable, 'error' => $probeError, 'php_user' => $phpUser, 'fix_hint' => $writable || !$enabled ? null : 'On the server run: mkdir -p ' . $dir . ' && chown -R www-data:www-data ' . dirname($dir) . ' && chmod -R 775 ' . dirname($dir) . ' (adjust www-data to your PHP user). Or set QDB_API_LOG_DIR in .env to a writable path.', ]; } function qdb_api_log_max_bytes(): int { $v = qdb_env_get('QDB_API_LOG_MAX_BYTES'); if ($v !== null && $v !== '' && ctype_digit($v)) { return max(1024 * 1024, (int)$v); } return 10 * 1024 * 1024; } function qdb_api_log_body_max_chars(): int { $v = qdb_env_get('QDB_API_LOG_BODY_MAX'); if ($v !== null && $v !== '' && ctype_digit($v)) { return max(256, (int)$v); } return 4096; } /** * Whether this request should be written to the activity log. */ function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string { $method = strtoupper($method); $client = strtolower(trim($client)); $route = strtolower(trim($route)); if ($method === 'OPTIONS' || in_array($route, ['activity-log', 'session'], true)) { return null; } if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { return $client === 'web' ? 'web_change' : 'app_change'; } if ($method === 'GET') { if ($route === 'app_questionnaires' && $client !== 'web') { return 'app_sync'; } if (qdb_api_log_is_website_download($route)) { return 'web_export'; } } return null; } /** Website GET that downloads or exports data (not routine list/load). */ function qdb_api_log_is_website_download(string $route): bool { if (in_array($route, ['export', 'backup'], true)) { return true; } if ($route === 'translations' && !empty($_GET['exportBundle'])) { return true; } return false; } /** @param array $context */ function qdb_api_log_begin(string $method, string $route, array $context = []): void { if (!qdb_api_log_enabled()) { return; } $client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? '')); $activity = qdb_api_log_classify_activity($method, $client, $route); $GLOBALS['qdb_api_log_ctx'] = array_merge([ 'started_at' => microtime(true), 'method' => strtoupper($method), 'route' => $route, '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'])) { 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()) { return; } $started = (float)($ctx['started_at'] ?? microtime(true)); $status = http_response_code(); if ($status === false || $status === 0) { $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 !== '') { $auth['token_hint'] = qdb_api_log_token_hint($token); try { $rec = token_get_record($token); if (is_array($rec)) { $auth['role'] = $rec['role'] ?? null; $auth['userID'] = $rec['userID'] ?? null; if (!empty($rec['temp'])) { $auth['temp_token'] = true; } } } catch (Throwable) { $auth['token_lookup'] = 'failed'; } } $rawBody = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : ''; $bodyLog = qdb_api_log_format_body($rawBody); $queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? '')); $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' => $activity, 'method' => $ctx['method'] ?? '', 'route' => $ctx['route'] ?? '', 'status' => $status, 'duration_ms' => (int)round((microtime(true) - $started) * 1000), 'ip' => $ctx['ip'] ?? '', 'client' => $ctx['client'] ?? '', 'uri' => $ctx['uri'] ?? '', 'query' => $queryLog, 'role' => $auth['role'], 'userID' => $auth['userID'], 'actor_username' => $actorUsername, 'target_username' => $targetUsername, 'token_hint' => $auth['token_hint'], 'temp_token' => $auth['temp_token'] ?? null, 'changes' => qdb_api_log_build_changes( (string)($ctx['method'] ?? ''), (string)($ctx['route'] ?? ''), $bodyLog, $queryLog ), ]; 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_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'] ?? '') !== '') { $entry['user_agent'] = $ctx['user_agent']; } try { $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($line === false) { return; } qdb_api_log_append_line($line . "\n"); } catch (Throwable $e) { error_log('api_log write failed: ' . $e->getMessage()); } } function qdb_api_log_client_ip(): string { foreach ([ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR', ] as $key) { $v = trim((string)($_SERVER[$key] ?? '')); if ($v === '') { continue; } if ($key === 'HTTP_X_FORWARDED_FOR') { $v = trim(explode(',', $v)[0]); } if (filter_var($v, FILTER_VALIDATE_IP)) { return $v; } } return ''; } function qdb_api_log_token_hint(string $token): string { $len = strlen($token); if ($len <= 8) { return '[token]'; } return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)"; } function qdb_api_log_redact_query_string(string $qs): ?string { if ($qs === '') { return null; } parse_str($qs, $params); if (!is_array($params) || $params === []) { return $qs; } foreach ($params as $k => $_) { if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) { $params[$k] = '[REDACTED]'; } } return http_build_query($params); } /** * @return array|string|null */ function qdb_api_log_format_body(string $raw): array|string|null { $raw = trim($raw); if ($raw === '') { return null; } $max = qdb_api_log_body_max_chars(); $decoded = json_decode($raw, true); if (is_array($decoded)) { $redacted = qdb_api_log_redact_value($decoded); $json = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) { return '[unencodable body]'; } if (strlen($json) > $max) { return substr($json, 0, $max) . '…[truncated]'; } return $redacted; } if (strlen($raw) > $max) { return substr($raw, 0, $max) . '…[truncated]'; } return $raw; } /** @param mixed $value @return mixed */ function qdb_api_log_redact_value(mixed $value): mixed { if (!is_array($value)) { return $value; } $isList = array_keys($value) === range(0, count($value) - 1); $out = []; foreach ($value as $k => $v) { $lk = strtolower((string)$k); if (in_array($lk, [ 'password', 'old_password', 'new_password', 'passwordhash', 'token', 'authorization', 'secret', ], true)) { $out[$k] = '[REDACTED]'; } elseif ($lk === 'encrypted' && is_string($v)) { $out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]'; } elseif (is_array($v)) { $out[$k] = qdb_api_log_redact_value($v); } else { $out[$k] = $v; } } return $isList ? array_values($out) : $out; } /** * Field-level change list for the activity log UI (passwords stay redacted). * * @param mixed $body Redacted decoded body or null * @return list */ function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array { $changes = []; $max = 100; if ($queryString !== null && $queryString !== '') { parse_str($queryString, $params); if (is_array($params)) { foreach ($params as $k => $v) { if (count($changes) >= $max) { break; } if (!is_scalar($v)) { continue; } $changes[] = [ 'field' => 'query.' . (string)$k, 'value' => qdb_api_log_change_scalar($v), ]; } } } if (is_array($body)) { qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max); } elseif (is_string($body) && $body !== '') { $changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)]; } if ($changes === [] && strtoupper($method) === 'DELETE') { $changes[] = ['field' => '_action', 'value' => 'delete']; } return $changes; } /** * @param list $out */ function qdb_api_log_flatten_for_changes( array $data, string $prefix, array &$out, int $depth, int $maxDepth, int $maxFields ): void { if ($depth >= $maxDepth || count($out) >= $maxFields) { return; } foreach ($data as $k => $v) { if (count($out) >= $maxFields) { return; } $key = (string)$k; $field = $prefix === '' ? $key : $prefix . '.' . $key; if (is_array($v)) { $childList = array_keys($v) === range(0, count($v) - 1); $n = count($v); if ($n === 0) { $out[] = ['field' => $field, 'value' => '[]']; continue; } if ($childList && $n > 8) { $out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]']; continue; } if (!$childList && $depth + 1 >= $maxDepth) { $out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]']; continue; } qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields); } else { $out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)]; } } } /** @return string|int|float|bool|null */ function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null { if ($v === null) { return null; } if (is_bool($v) || is_int($v) || is_float($v)) { return $v; } $s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE); if ($s === false) { return ''; } if (strlen($s) > 800) { return substr($s, 0, 800) . '…'; } return $s; } function qdb_api_log_append_line(string $line): void { $dir = qdb_api_log_dir(); if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { $err = error_get_last(); throw new RuntimeException( 'Cannot create log directory: ' . $dir . ($err ? ' (' . ($err['message'] ?? '') . ')' : '') ); } if (!is_writable($dir)) { throw new RuntimeException('Log directory not writable: ' . $dir); } $path = qdb_api_log_resolve_write_path($dir); if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) { $err = error_get_last(); throw new RuntimeException( 'Cannot write API log: ' . $path . ($err ? ' (' . ($err['message'] ?? '') . ')' : '') ); } } function qdb_api_log_resolve_write_path(string $dir): string { $date = date('Y-m-d'); $base = $dir . '/api-' . $date; $max = qdb_api_log_max_bytes(); $primary = $base . '.log'; if (!is_file($primary) || filesize($primary) < $max) { return $primary; } for ($part = 2; $part <= 999; $part++) { $path = sprintf('%s-%03d.log', $base, $part); if (!is_file($path) || filesize($path) < $max) { return $path; } } return sprintf('%s-%03d.log', $base, 999); } /** * Read activity log entries for admin UI (newest first). * * @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, 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 = ''; } $dir = qdb_api_log_dir(); $files = qdb_api_log_files_for_date($dir, $date); $entries = []; foreach ($files as $file) { $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if ($lines === false) { continue; } foreach ($lines as $line) { $row = json_decode($line, true); if (!is_array($row)) { continue; } 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); } } usort($entries, static function (array $a, array $b): int { return strcmp($b['ts'] ?? '', $a['ts'] ?? ''); }); $truncated = count($entries) > $limit; if ($truncated) { $entries = array_slice($entries, 0, $limit); } $entries = qdb_api_log_enrich_entries_usernames($entries); return [ '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(), ]; } /** @return list */ function qdb_api_log_files_for_date(string $dir, string $date): array { if (!is_dir($dir)) { return []; } $pattern = $dir . '/api-' . $date . '*.log'; $files = glob($pattern) ?: []; sort($files); return $files; } /** @param array $row @return array */ function qdb_api_log_entry_for_ui(array $row): array { $changes = $row['changes'] ?? null; if (!is_array($changes) || $changes === []) { $changes = qdb_api_log_build_changes( (string)($row['method'] ?? ''), (string)($row['route'] ?? ''), $row['body'] ?? null, isset($row['query']) ? (string)$row['query'] : null ); } $changes = qdb_api_log_sort_changes_for_display($changes); $actorUsername = $row['actor_username'] ?? null; $targetUsername = $row['target_username'] ?? null; return [ 'ts' => $row['ts'] ?? '', 'activity' => $row['activity'] ?? '', 'method' => $row['method'] ?? '', 'route' => $row['route'] ?? '', 'status' => $row['status'] ?? null, 'duration_ms' => $row['duration_ms'] ?? null, 'role' => $row['role'] ?? null, 'userID' => $row['userID'] ?? null, 'actor_username' => is_string($actorUsername) ? $actorUsername : null, 'target_username' => is_string($targetUsername) ? $targetUsername : null, 'client' => $row['client'] ?? '', 'ip' => $row['ip'] ?? '', 'query' => $row['query'] ?? null, '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, ]; } function qdb_api_log_lookup_username(?string $userID): ?string { if ($userID === null || $userID === '') { return null; } try { require_once __DIR__ . '/../db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(false); $stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid'); $stmt->execute([':uid' => $userID]); $name = $stmt->fetchColumn(); qdb_discard($tmpDb, $lockFp); return $name !== false ? (string)$name : null; } catch (Throwable) { return null; } } function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string { if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) { return trim((string)$body['username']); } if ($queryString !== null && $queryString !== '') { parse_str($queryString, $params); if (!empty($params['username']) && is_scalar($params['username'])) { return trim((string)$params['username']); } } return null; } /** * Resolve userID / supervisorID in log rows to usernames for retraceability. * * @param list> $entries * @return list> */ function qdb_api_log_enrich_entries_usernames(array $entries): array { if ($entries === []) { return $entries; } $userIds = []; $supervisorIds = []; foreach ($entries as $entry) { if (!empty($entry['userID']) && is_string($entry['userID'])) { $userIds[$entry['userID']] = true; } if (empty($entry['actor_username']) && !empty($entry['userID'])) { $userIds[$entry['userID']] = true; } foreach ($entry['changes'] ?? [] as $change) { if (!is_array($change)) { continue; } $field = strtolower((string)($change['field'] ?? '')); $value = $change['value'] ?? null; if (!is_string($value) || $value === '' || str_starts_with($value, '[')) { continue; } if ($field === 'userid' || str_ends_with($field, '.userid')) { $userIds[$value] = true; } if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) { $supervisorIds[$value] = true; } } } $userMap = qdb_api_log_load_user_map(array_keys($userIds)); $svMap = qdb_api_log_load_supervisor_map(array_keys($supervisorIds)); foreach ($entries as $i => $entry) { if (empty($entry['actor_username']) && !empty($entry['userID'])) { $entry['actor_username'] = $userMap[$entry['userID']] ?? null; } if (empty($entry['target_username'])) { $entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []); } $enriched = []; foreach ($entry['changes'] ?? [] as $change) { if (!is_array($change)) { continue; } $change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap); $enriched[] = $change; } $entry['changes'] = qdb_api_log_sort_changes_for_display($enriched); $entry['summary'] = qdb_api_log_summarize_changes( $entry['changes'], $entry['target_username'] ?? null ); $entries[$i] = $entry; } return $entries; } /** @param list $userIds @return array */ function qdb_api_log_load_user_map(array $userIds): array { $userIds = array_values(array_filter($userIds, static fn($id) => $id !== '')); if ($userIds === []) { return []; } try { require_once __DIR__ . '/../db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(false); $placeholders = implode(',', array_fill(0, count($userIds), '?')); $stmt = $pdo->prepare( "SELECT userID, username FROM users WHERE userID IN ($placeholders)" ); $stmt->execute($userIds); $map = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $map[(string)$row['userID']] = (string)$row['username']; } qdb_discard($tmpDb, $lockFp); return $map; } catch (Throwable) { return []; } } /** @param list $supervisorIds @return array */ function qdb_api_log_load_supervisor_map(array $supervisorIds): array { $supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== '')); if ($supervisorIds === []) { return []; } try { require_once __DIR__ . '/../db_init.php'; [$pdo, $tmpDb, $lockFp] = qdb_open(false); $placeholders = implode(',', array_fill(0, count($supervisorIds), '?')); $stmt = $pdo->prepare( "SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)" ); $stmt->execute($supervisorIds); $map = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $map[(string)$row['supervisorID']] = (string)$row['username']; } qdb_discard($tmpDb, $lockFp); return $map; } catch (Throwable) { return []; } } /** @param list $changes */ function qdb_api_log_target_from_changes(array $changes): ?string { foreach ($changes as $c) { $field = strtolower((string)($c['field'] ?? '')); if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) { return trim((string)$c['value']); } } return null; } /** * @param array $userMap * @param array $svMap */ function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string { $field = strtolower((string)($change['field'] ?? '')); $value = $change['value'] ?? null; if ($value === null) { return 'null'; } if (!is_scalar($value)) { return ''; } $str = (string)$value; if ($field === 'username') { return $str; } if ($field === 'userid' || str_ends_with($field, '.userid')) { return $userMap[$str] ?? $str; } if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) { return $svMap[$str] ?? $str; } if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) { return $str; } if ($field === 'mustchangepassword') { return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)'; } if ($field === 'password' || str_contains($field, 'password')) { return (string)$value; } return $str; } /** * @param list $changes * @return list */ function qdb_api_log_sort_changes_for_display(array $changes): array { $priority = [ 'username' => 0, 'type' => 1, 'text' => 2, 'languagecode' => 3, 'id' => 4, 'role' => 5, 'clientcode' => 6, 'action' => 7, 'query.id' => 10, 'query.clients' => 11, 'query.clientcode' => 12, 'query.answers' => 13, 'query.translations' => 14, 'query.bundle' => 15, 'query.exportall' => 16, 'query.exportbundle' => 17, 'supervisorid' => 18, 'userid' => 19, 'mustchangepassword' => 20, 'password' => 21, 'location' => 22, ]; usort($changes, static function (array $a, array $b) use ($priority): int { $fa = strtolower((string)($a['field'] ?? '')); $fb = strtolower((string)($b['field'] ?? '')); $pa = $priority[$fa] ?? 50; $pb = $priority[$fb] ?? 50; if ($pa !== $pb) { return $pa <=> $pb; } return strcmp($fa, $fb); }); return $changes; } /** @param list $changes */ function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string { if ($targetUsername !== null && $targetUsername !== '') { return 'user=' . $targetUsername; } if ($changes === []) { return ''; } $parts = []; foreach (array_slice($changes, 0, 8) as $c) { $field = (string)($c['field'] ?? ''); if ($field === '') { continue; } $label = (string)($c['display'] ?? ''); $value = $c['value'] ?? ''; if ($label !== '' && $label !== (string)$value) { $parts[] = $field . '=' . $label; } elseif ($value === null) { $parts[] = $field . '=null'; } elseif (is_bool($value)) { $parts[] = $field . '=' . ($value ? 'true' : 'false'); } else { $parts[] = $field . '=' . (string)$value; } } $summary = implode(', ', $parts); if (count($changes) > 8) { $summary .= ', …'; } if (strlen($summary) > 240) { $summary = substr($summary, 0, 240) . '…'; } return $summary; }