diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 690737b..2faf833 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -14,8 +14,9 @@ $routes->options('(:any)', function () { }); $routes->group('api', ['filter' => 'auth'], function ($routes) { - $routes->get('dashboard', 'DashboardController::index'); - $routes->get('sample', 'SampleController::index'); + $routes->get('dashboard', 'DashboardController::index'); + $routes->get('sample', 'SampleController::index'); + $routes->get('audit-logs', 'Audit\AuditLogController::index'); // Results CRUD $routes->group('result', function ($routes) { diff --git a/app/Controllers/Audit/AuditLogController.php b/app/Controllers/Audit/AuditLogController.php new file mode 100644 index 0000000..f74984a --- /dev/null +++ b/app/Controllers/Audit/AuditLogController.php @@ -0,0 +1,60 @@ +auditLogService = new AuditLogService(); + } + + public function index(): ResponseInterface + { + $filters = [ + 'table' => $this->request->getGet('table'), + 'rec_id' => $this->request->getGet('rec_id') ?? $this->request->getGet('recId'), + 'event_id' => $this->request->getGet('event_id') ?? $this->request->getGet('eventId'), + 'activity_id' => $this->request->getGet('activity_id') ?? $this->request->getGet('activityId'), + 'from' => $this->request->getGet('from'), + 'to' => $this->request->getGet('to'), + 'search' => $this->request->getGet('search'), + 'page' => $this->request->getGet('page'), + 'perPage' => $this->request->getGet('perPage') ?? $this->request->getGet('per_page'), + ]; + + try { + $payload = $this->auditLogService->fetchLogs($filters); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Audit logs retrieved successfully', + 'data' => $payload, + ], 200); + + } catch (InvalidArgumentException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => $e->getMessage(), + 'data' => null, + ], 400); + } catch (\Throwable $e) { + log_message('error', 'AuditLogController::index error: ' . $e->getMessage()); + return $this->respond([ + 'status' => 'failed', + 'message' => 'Unable to retrieve audit logs', + 'data' => null, + ], 500); + } + } +} diff --git a/app/Services/AuditLogService.php b/app/Services/AuditLogService.php new file mode 100644 index 0000000..073465e --- /dev/null +++ b/app/Services/AuditLogService.php @@ -0,0 +1,179 @@ + '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 PRIMARY_KEYS = [ + 'logpatient' => 'LogPatientID', + 'logorder' => 'LogOrderID', + 'logmaster' => 'LogMasterID', + 'logsystem' => 'LogSystemID', + ]; + + private const DEFAULT_PAGE = 1; + private const DEFAULT_PER_PAGE = 20; + private const MAX_PER_PAGE = 100; + + private static ?BaseConnection $db = null; + + public function fetchLogs(array $filters): array + { + $tableKey = $filters['table'] ?? null; + if (empty($tableKey)) { + throw new InvalidArgumentException('table parameter is required'); + } + + $logTable = $this->resolveLogTable($tableKey); + if ($logTable === null) { + throw new InvalidArgumentException("Unknown audit table: {$tableKey}"); + } + + $builder = $this->getDb()->table($logTable); + + $this->applyFilters($builder, $filters); + + $total = (int) $builder->countAllResults(false); + + $page = $this->normalizePage($filters['page'] ?? null); + $perPage = $this->normalizePerPage($filters['perPage'] ?? $filters['per_page'] ?? null); + $offset = ($page - 1) * $perPage; + + $builder->orderBy('LogDate', 'DESC'); + $builder->orderBy($this->getPrimaryKey($logTable), 'DESC'); + + $rows = $builder + ->limit($perPage, $offset) + ->get() + ->getResultArray(); + + return [ + 'data' => $rows, + 'pagination' => [ + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + ], + ]; + } + + private function applyFilters($builder, array $filters): void + { + if (!empty($filters['rec_id'])) { + $builder->where('RecID', (string) $filters['rec_id']); + } + + if (!empty($filters['event_id'])) { + $builder->where('EventID', $this->normalizeCode($filters['event_id'])); + } + + if (!empty($filters['activity_id'])) { + $builder->where('ActivityID', $this->normalizeCode($filters['activity_id'])); + } + + $this->applyDateRange($builder, $filters['from'] ?? null, $filters['to'] ?? null); + + if (!empty($filters['search'])) { + $search = trim($filters['search']); + if ($search !== '') { + $builder->groupStart(); + $builder->like('UserID', $search); + $builder->orLike('Reason', $search); + $builder->orLike('FldName', $search); + $builder->orLike('FldValuePrev', $search); + $builder->orLike('FldValueNew', $search); + $builder->orLike('EventID', $search); + $builder->orLike('ActivityID', $search); + $builder->groupEnd(); + } + } + } + + private function applyDateRange($builder, ?string $from, ?string $to): void + { + if ($from !== null && trim($from) !== '') { + $builder->where('LogDate >=', $this->normalizeDate($from)); + } + if ($to !== null && trim($to) !== '') { + $builder->where('LogDate <=', $this->normalizeDate($to)); + } + } + + private function normalizeDate(string $value): string + { + try { + $dt = new DateTime($value, new DateTimeZone('UTC')); + } catch (Throwable $e) { + throw new InvalidArgumentException('Invalid date: ' . $value); + } + + return $dt->format('Y-m-d H:i:s'); + } + + private function normalizeCode(string $value): string + { + return strtoupper(trim($value)); + } + + private function normalizePage($value): int + { + $page = (int) ($value ?? self::DEFAULT_PAGE); + return $page < 1 ? self::DEFAULT_PAGE : $page; + } + + private function normalizePerPage($value): int + { + $perPage = (int) ($value ?? self::DEFAULT_PER_PAGE); + if ($perPage < 1) { + return self::DEFAULT_PER_PAGE; + } + if ($perPage > self::MAX_PER_PAGE) { + throw new InvalidArgumentException('perPage cannot be greater than ' . self::MAX_PER_PAGE); + } + return $perPage; + } + + private function resolveLogTable(?string $key): ?string + { + if ($key === null) { + return null; + } + + $lookup = strtolower(trim($key)); + return self::TABLE_MAP[$lookup] ?? null; + } + + private function getPrimaryKey(string $table): string + { + return self::PRIMARY_KEYS[$table] ?? 'LogID'; + } + + private function getDb(): BaseConnection + { + return self::$db ??= \Config\Database::connect(); + } +} diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index f4db3be..ba6cd97 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -57,7 +57,86 @@ tags: description: User management and administration - name: Demo description: Demo/test endpoints (no authentication) + - name: Audit + description: Audit log retrieval and filtering paths: + /api/audit-logs: + get: + tags: + - Audit + summary: Retrieve audit log entries for a table + security: + - bearerAuth: [] + parameters: + - name: table + in: query + required: true + schema: + type: string + description: Table alias for the audit data (logpatient, logorder, logmaster, logsystem) + - name: rec_id + in: query + schema: + type: string + description: Primary record identifier (RecID) to filter audit rows + - name: event_id + in: query + schema: + type: string + description: Canonical EventID (case insensitive) + - name: activity_id + in: query + schema: + type: string + description: Canonical ActivityID (case insensitive) + - name: from + in: query + schema: + type: string + format: date-time + description: Lower bound for LogDate inclusive + - name: to + in: query + schema: + type: string + format: date-time + description: Upper bound for LogDate inclusive + - name: search + in: query + schema: + type: string + description: Search term that matches user, reason, field names, or values + - name: page + in: query + schema: + type: integer + default: 1 + description: Page number + - name: perPage + in: query + schema: + type: integer + default: 20 + description: Items per page (max 100) + responses: + '200': + description: Audit log results + content: + application/json: + schema: + $ref: '#/components/schemas/AuditLogsEnvelope' + '400': + description: Validation failure (missing table or invalid filters) + content: + application/json: + schema: + $ref: '#/components/schemas/AuditLogsErrorResponse' + '500': + description: Internal error when retrieving audit logs + content: + application/json: + schema: + $ref: '#/components/schemas/AuditLogsErrorResponse' /api/auth/login: post: tags: @@ -7874,6 +7953,121 @@ components: type: string format: date-time nullable: true + AuditLogEntry: + type: object + properties: + LogPatientID: + type: integer + nullable: true + LogOrderID: + type: integer + nullable: true + LogMasterID: + type: integer + nullable: true + LogSystemID: + type: integer + nullable: true + TblName: + type: string + RecID: + type: string + FldName: + type: string + nullable: true + FldValuePrev: + type: string + nullable: true + FldValueNew: + type: string + nullable: true + UserID: + type: string + SiteID: + type: string + DIDType: + type: string + nullable: true + DID: + type: string + nullable: true + MachineID: + type: string + nullable: true + SessionID: + type: string + AppID: + type: string + ProcessID: + type: string + nullable: true + WebPageID: + type: string + nullable: true + EventID: + type: string + ActivityID: + type: string + Reason: + type: string + nullable: true + LogDate: + type: string + format: date-time + Context: + type: string + IpAddress: + type: string + nullable: true + AuditLogListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AuditLogEntry' + pagination: + type: object + properties: + page: + type: integer + perPage: + type: integer + total: + type: integer + required: + - page + - perPage + - total + required: + - data + - pagination + AuditLogsEnvelope: + type: object + properties: + status: + type: string + message: + type: string + data: + $ref: '#/components/schemas/AuditLogListResponse' + required: + - status + - message + - data + AuditLogsErrorResponse: + type: object + properties: + status: + type: string + message: + type: string + data: + nullable: true + required: + - status + - message + - data Contact: type: object properties: diff --git a/public/api-docs.yaml b/public/api-docs.yaml index 6b6ce5a..6345cc1 100644 --- a/public/api-docs.yaml +++ b/public/api-docs.yaml @@ -59,6 +59,8 @@ tags: description: User management and administration - name: Demo description: Demo/test endpoints (no authentication) + - name: Audit + description: Audit log retrieval and filtering components: securitySchemes: diff --git a/public/components/schemas/audit-logs.yaml b/public/components/schemas/audit-logs.yaml new file mode 100644 index 0000000..c3a6eeb --- /dev/null +++ b/public/components/schemas/audit-logs.yaml @@ -0,0 +1,107 @@ +AuditLogEntry: + type: object + properties: + LogPatientID: + type: integer + nullable: true + LogOrderID: + type: integer + nullable: true + LogMasterID: + type: integer + nullable: true + LogSystemID: + type: integer + nullable: true + TblName: + type: string + RecID: + type: string + FldName: + type: string + nullable: true + FldValuePrev: + type: string + nullable: true + FldValueNew: + type: string + nullable: true + UserID: + type: string + SiteID: + type: string + DIDType: + type: string + nullable: true + DID: + type: string + nullable: true + MachineID: + type: string + nullable: true + SessionID: + type: string + AppID: + type: string + ProcessID: + type: string + nullable: true + WebPageID: + type: string + nullable: true + EventID: + type: string + ActivityID: + type: string + Reason: + type: string + nullable: true + LogDate: + type: string + format: date-time + Context: + type: string + IpAddress: + type: string + nullable: true + +AuditLogListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/AuditLogEntry' + pagination: + type: object + properties: + page: + type: integer + perPage: + type: integer + total: + type: integer + required: [page, perPage, total] + required: [data, pagination] + +AuditLogsEnvelope: + type: object + properties: + status: + type: string + message: + type: string + data: + $ref: '#/AuditLogListResponse' + required: [status, message, data] + +AuditLogsErrorResponse: + type: object + properties: + status: + type: string + message: + type: string + data: + nullable: true + required: [status, message, data] diff --git a/public/paths/audit-logs.yaml b/public/paths/audit-logs.yaml new file mode 100644 index 0000000..db0a7ff --- /dev/null +++ b/public/paths/audit-logs.yaml @@ -0,0 +1,76 @@ +/api/audit-logs: + get: + tags: [Audit] + summary: Retrieve audit log entries for a table + security: + - bearerAuth: [] + parameters: + - name: table + in: query + required: true + schema: + type: string + description: Table alias for the audit data (logpatient, logorder, logmaster, logsystem) + - name: rec_id + in: query + schema: + type: string + description: Primary record identifier (RecID) to filter audit rows + - name: event_id + in: query + schema: + type: string + description: Canonical EventID (case insensitive) + - name: activity_id + in: query + schema: + type: string + description: Canonical ActivityID (case insensitive) + - name: from + in: query + schema: + type: string + format: date-time + description: Lower bound for LogDate inclusive + - name: to + in: query + schema: + type: string + format: date-time + description: Upper bound for LogDate inclusive + - name: search + in: query + schema: + type: string + description: Search term that matches user, reason, field names, or values + - name: page + in: query + schema: + type: integer + default: 1 + description: Page number + - name: perPage + in: query + schema: + type: integer + default: 20 + description: Items per page (max 100) + responses: + '200': + description: Audit log results + content: + application/json: + schema: + $ref: '../components/schemas/audit-logs.yaml#/AuditLogsEnvelope' + '400': + description: Validation failure (missing table or invalid filters) + content: + application/json: + schema: + $ref: '../components/schemas/audit-logs.yaml#/AuditLogsErrorResponse' + '500': + description: Internal error when retrieving audit logs + content: + application/json: + schema: + $ref: '../components/schemas/audit-logs.yaml#/AuditLogsErrorResponse' diff --git a/tests/feature/Audit/AuditLogTest.php b/tests/feature/Audit/AuditLogTest.php new file mode 100644 index 0000000..8069692 --- /dev/null +++ b/tests/feature/Audit/AuditLogTest.php @@ -0,0 +1,117 @@ +db = \Config\Database::connect(); + $this->db->table('logpatient')->insert([ + 'TblName' => 'patient', + 'RecID' => $this->testRecId, + 'UserID' => 'USR_TEST', + 'SiteID' => 'SITE01', + 'SessionID' => 'sess_test', + 'AppID' => 'clqms-api', + 'EventID' => 'PATIENT_REGISTERED', + 'ActivityID' => 'CREATE', + 'LogDate' => '2026-03-25 12:00:00', + 'Context' => json_encode([ + 'request_id' => 'req-test-1', + 'route' => 'POST /api/patient', + 'timestamp_utc' => '2026-03-25T12:00:00.000Z', + 'entity_type' => 'patient', + 'entity_version' => 1, + ]), + ]); + } + + protected function tearDown(): void + { + $this->db->table('logpatient')->where('RecID', $this->testRecId)->delete(); + parent::tearDown(); + } + + public function testTableIsRequired() + { + $result = $this->getWithAuth('api/audit-logs'); + + $result->assertStatus(400); + $result->assertJSONFragment([ + 'status' => 'failed', + 'message' => 'table parameter is required', + ]); + } + + public function testUnknownTableReturnsValidationError() + { + $result = $this->getWithAuth('api/audit-logs?table=unknown'); + + $result->assertStatus(400); + $result->assertJSONFragment([ + 'status' => 'failed', + 'message' => 'Unknown audit table: unknown', + ]); + } + + public function testAuditLogsFilterByRecId() + { + $result = $this->getWithAuth('api/audit-logs?table=logpatient&rec_id=' . $this->testRecId); + + $result->assertStatus(200); + $result->assertJSONFragment([ + 'status' => 'success', + ]); + + $payload = json_decode($result->getJSON(), true); + $this->assertCount(1, $payload['data']['data']); + $this->assertEquals($this->testRecId, $payload['data']['data'][0]['RecID']); + + $pagination = $payload['data']['pagination']; + $this->assertSame(1, $pagination['page']); + $this->assertSame(20, $pagination['perPage']); + $this->assertSame(1, $pagination['total']); + } + + private function getWithAuth(string $uri) + { + $_COOKIE['token'] = $this->buildToken(); + + $response = $this->get($uri); + + unset($_COOKIE['token']); + + return $response; + } + + private function buildToken(): string + { + $payload = [ + 'sub' => 'audit-test', + 'iat' => time(), + ]; + + return JWT::encode($payload, $this->resolveSecret(), 'HS256'); + } + + private function resolveSecret(): string + { + $secret = getenv('JWT_SECRET'); + if ($secret === false) { + return 'tests-secret'; + } + return trim($secret, "'\""); + } +}