feat: add audit log query endpoint

This commit is contained in:
mahdahar 2026-03-25 16:52:11 +07:00
parent 51fa8c1949
commit a73b88bc05
8 changed files with 738 additions and 2 deletions

View File

@ -16,6 +16,7 @@ $routes->options('(:any)', function () {
$routes->group('api', ['filter' => 'auth'], function ($routes) { $routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('dashboard', 'DashboardController::index'); $routes->get('dashboard', 'DashboardController::index');
$routes->get('sample', 'SampleController::index'); $routes->get('sample', 'SampleController::index');
$routes->get('audit-logs', 'Audit\AuditLogController::index');
// Results CRUD // Results CRUD
$routes->group('result', function ($routes) { $routes->group('result', function ($routes) {

View File

@ -0,0 +1,60 @@
<?php
namespace App\Controllers\Audit;
use App\Controllers\BaseController;
use App\Services\AuditLogService;
use App\Traits\ResponseTrait;
use CodeIgniter\HTTP\ResponseInterface;
use InvalidArgumentException;
class AuditLogController extends BaseController
{
use ResponseTrait;
private AuditLogService $auditLogService;
public function __construct()
{
$this->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);
}
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
use DateTime;
use DateTimeZone;
use InvalidArgumentException;
use Throwable;
class AuditLogService
{
private const TABLE_MAP = [
'logpatient' => '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();
}
}

View File

@ -57,7 +57,86 @@ tags:
description: User management and administration description: User management and administration
- name: Demo - name: Demo
description: Demo/test endpoints (no authentication) description: Demo/test endpoints (no authentication)
- name: Audit
description: Audit log retrieval and filtering
paths: 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: /api/auth/login:
post: post:
tags: tags:
@ -7874,6 +7953,121 @@ components:
type: string type: string
format: date-time format: date-time
nullable: true 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: Contact:
type: object type: object
properties: properties:

View File

@ -59,6 +59,8 @@ tags:
description: User management and administration description: User management and administration
- name: Demo - name: Demo
description: Demo/test endpoints (no authentication) description: Demo/test endpoints (no authentication)
- name: Audit
description: Audit log retrieval and filtering
components: components:
securitySchemes: securitySchemes:

View File

@ -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]

View File

@ -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'

View File

@ -0,0 +1,117 @@
<?php
namespace Tests\Feature\Audit;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Firebase\JWT\JWT;
class AuditLogTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $db;
private $testRecId = 'TEST-REC-123';
protected function setUp(): void
{
parent::setUp();
$this->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, "'\"");
}
}