'logpatient', 'patient' => 'logpatient', 'visit' => 'logpatient', 'logorder' => 'logorder', 'order' => 'logorder', 'specimen' => 'logorder', 'result' => 'logorder', 'logmaster' => 'logmaster', 'master' => 'logmaster', 'config' => 'logmaster', 'valueset' => 'logmaster', 'logsystem' => 'logsystem', 'system' => 'logsystem', 'auth' => 'logsystem', 'job' => 'logsystem', ]; private const DEFAULT_APP_ID = 'clqms-api'; private const ENTITY_VERSION_DEFAULT = 1; private static ?BaseConnection $db = null; private static $session = null; private static ?IncomingRequest $request = null; private static ?array $eventIdCache = null; private static ?string $cachedRequestId = null; public static function logData( string $eventId, string $activityId, string $entityType, string $entityId, string $tableName, ?string $fldName = null, $previousValue = null, $newValue = null, ?string $reason = null, ?array $context = null, array $options = [] ): void { $sourceTable = $tableName ?: $entityType; $logTable = self::resolveLogTable($sourceTable); if ($logTable === null) { log_message('warning', "AuditService cannot resolve log table for {$sourceTable}"); return; } $record = self::buildRecord( $logTable, self::normalizeEventId($eventId), strtoupper($activityId), $entityType, $entityId, $sourceTable, $fldName, $previousValue, $newValue, $reason, $context, $options ); if ($record === null) { return; } try { self::getDb()->table($logTable)->insert($record); } catch (\Throwable $e) { log_message('error', "AuditService failed to insert into {$logTable}: {$e->getMessage()}"); } } private static function buildRecord( string $logTable, string $eventId, string $activityId, string $entityType, string $entityId, string $tblName, ?string $fldName, $previousValue, $newValue, ?string $reason, ?array $context, array $options ): ?array { $contextJson = self::buildContext($context, $options, $entityType); if ($contextJson === null) { return null; } return [ 'TblName' => $tblName, 'RecID' => (string) $entityId, 'FldName' => $fldName, 'FldValuePrev' => self::serializeValue($previousValue), 'FldValueNew' => self::serializeValue($newValue), 'UserID' => self::resolveUserId($options), 'SiteID' => self::resolveSiteId($options), 'DIDType' => $options['did_type'] ?? null, 'DID' => $options['did'] ?? null, 'MachineID' => $options['machine_id'] ?? gethostname(), 'SessionID' => self::resolveSessionId($options), 'AppID' => $options['app_id'] ?? self::DEFAULT_APP_ID, 'ProcessID' => $options['process_id'] ?? null, 'WebPageID' => $options['web_page_id'] ?? self::resolveRoute($options), 'EventID' => $eventId, 'ActivityID' => $activityId, 'Reason' => $reason, 'LogDate' => self::nowWithMillis(), 'Context' => $contextJson, 'IpAddress' => self::resolveIpAddress(), ]; } private static function serializeValue($value): ?string { if ($value === null) { return null; } if (is_scalar($value)) { return (string) $value; } $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return $json !== false ? $json : null; } private static function buildContext(?array $context, array $options, string $entityType): ?string { $route = $options['route'] ?? self::resolveRoute($options); $payload = array_merge( [ 'request_id' => $options['request_id'] ?? self::resolveRequestId(), 'route' => $route, 'timestamp_utc' => $options['timestamp_utc'] ?? self::timestampUtc(), 'entity_type' => $options['entity_type'] ?? $entityType, 'entity_version' => $options['entity_version'] ?? self::ENTITY_VERSION_DEFAULT, ], $context ?? [] ); $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return $json !== false ? $json : null; } private static function resolveLogTable(?string $source): ?string { if ($source === null) { return null; } $key = strtolower(trim($source)); return self::TABLE_MAP[$key] ?? null; } private static function resolveUserId(array $options): string { return $options['user_id'] ?? self::getSessionValue('user_id') ?? 'SYSTEM'; } private static function resolveSiteId(array $options): string { return $options['site_id'] ?? self::getSessionValue('site_id') ?? 'GLOBAL'; } private static function resolveSessionId(array $options): string { if (!empty($options['session_id'])) { return $options['session_id']; } $session = self::getSession(); if ($session !== null && method_exists($session, 'getId')) { $id = $session->getId(); if (!empty($id)) { return $id; } } if (session_status() === PHP_SESSION_ACTIVE) { $id = session_id(); if (!empty($id)) { return $id; } } return self::generateUniqueId('sess'); } private static function resolveRoute(array $options): string { if (!empty($options['route'])) { return $options['route']; } $request = self::getRequest(); if ($request !== null) { return trim(sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath())); } return 'cli'; } private static function resolveIpAddress(): ?string { $request = self::getRequest(); if ($request !== null) { return $request->getIPAddress(); } return $_SERVER['REMOTE_ADDR'] ?? null; } private static function normalizeEventId(string $eventId): string { $normalized = strtoupper(trim($eventId)); if (empty($normalized)) { log_message('warning', 'AuditService received empty EventID'); return $eventId; } if (!self::isKnownEvent($normalized)) { log_message('warning', "AuditService unknown EventID: {$normalized}"); } return $normalized; } private static function isKnownEvent(string $eventId): bool { if (self::$eventIdCache === null) { $raw = ValueSet::getRaw('event_id') ?? []; self::$eventIdCache = array_filter(array_map(fn ($item) => $item['key'] ?? null, $raw)); } return in_array($eventId, self::$eventIdCache, true); } private static function resolveRequestId(): string { if (self::$cachedRequestId !== null) { return self::$cachedRequestId; } $request = self::getRequest(); if ($request !== null) { $value = $request->getHeaderLine('X-Request-ID'); if (!empty($value)) { self::$cachedRequestId = $value; return $value; } } foreach (['HTTP_X_REQUEST_ID', 'REQUEST_ID'] as $header) { if (!empty($_SERVER[$header])) { self::$cachedRequestId = $_SERVER[$header]; return self::$cachedRequestId; } } return self::$cachedRequestId = self::generateUniqueId('req'); } private static function nowWithMillis(): string { $dt = new DateTime('now', new DateTimeZone('UTC')); return $dt->format('Y-m-d H:i:s.v'); } private static function timestampUtc(): string { $dt = new DateTime('now', new DateTimeZone('UTC')); return $dt->format('Y-m-d\TH:i:s.v\Z'); } private static function getSessionValue(string $key): ?string { $session = self::getSession(); if ($session === null) { return null; } if (!method_exists($session, 'get')) { return null; } $value = $session->get($key); return $value !== null ? (string) $value : null; } private static function getSession(): ?object { if (self::$session !== null) { return self::$session; } try { return self::$session = Services::session(); } catch (\Throwable $e) { return self::$session = null; } } private static function getRequest(): ?IncomingRequest { if (self::$request !== null) { return self::$request; } try { return self::$request = Services::request(); } catch (\Throwable $e) { return self::$request = null; } } private static function getDb(): BaseConnection { return self::$db ??= \Config\Database::connect(); } private static function generateUniqueId(string $prefix): string { try { return $prefix . '_' . bin2hex(random_bytes(8)); } catch (\Throwable $e) { return uniqid("{$prefix}_", true); } } }