feat: expand audit logging service and docs

This commit is contained in:
mahdahar 2026-03-25 10:41:22 +07:00
parent 6ece30302f
commit 7600989bed
11 changed files with 1342 additions and 759 deletions

Binary file not shown.

View File

@ -0,0 +1,137 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAuditLogs extends Migration
{
private array $logTables = [
'logpatient' => 'LogPatientID',
'logorder' => 'LogOrderID',
'logmaster' => 'LogMasterID',
'logsystem' => 'LogSystemID',
];
public function up(): void
{
foreach ($this->logTables as $table => $pk) {
$this->createLogTable($table, $pk);
}
}
public function down(): void
{
foreach (array_reverse($this->logTables) as $table => $pk) {
$this->forge->dropTable($table, true);
}
}
private function createLogTable(string $table, string $primaryKey): void
{
$fields = [
$primaryKey => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'TblName' => [
'type' => 'VARCHAR',
'constraint' => 64,
],
'RecID' => [
'type' => 'VARCHAR',
'constraint' => 64,
],
'FldName' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'FldValuePrev' => [
'type' => 'TEXT',
'null' => true,
],
'FldValueNew' => [
'type' => 'TEXT',
'null' => true,
],
'UserID' => [
'type' => 'VARCHAR',
'constraint' => 64,
],
'SiteID' => [
'type' => 'VARCHAR',
'constraint' => 32,
],
'DIDType' => [
'type' => 'VARCHAR',
'constraint' => 32,
'null' => true,
],
'DID' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'MachineID' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'SessionID' => [
'type' => 'VARCHAR',
'constraint' => 128,
],
'AppID' => [
'type' => 'VARCHAR',
'constraint' => 64,
],
'ProcessID' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'WebPageID' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'EventID' => [
'type' => 'VARCHAR',
'constraint' => 80,
],
'ActivityID' => [
'type' => 'VARCHAR',
'constraint' => 24,
],
'Reason' => [
'type' => 'VARCHAR',
'constraint' => 512,
'null' => true,
],
'LogDate' => [
'type' => 'DATETIME',
'constraint' => 3,
],
'Context' => [
'type' => 'JSON',
],
'IpAddress' => [
'type' => 'VARCHAR',
'constraint' => 45,
'null' => true,
],
];
$this->forge->addField($fields);
$this->forge->addKey($primaryKey, true);
$this->forge->addKey(['LogDate'], false, false, "idx_{$table}_logdate");
$this->forge->addKey(['RecID', 'LogDate'], false, false, "idx_{$table}_recid_logdate");
$this->forge->addKey(['UserID', 'LogDate'], false, false, "idx_{$table}_userid_logdate");
$this->forge->addKey(['EventID', 'LogDate'], false, false, "idx_{$table}_eventid_logdate");
$this->forge->addKey(['SiteID', 'LogDate'], false, false, "idx_{$table}_site_logdate");
$this->forge->createTable($table, true);
}
}

View File

@ -10,6 +10,7 @@
{"file": "death_indicator.json","VSName": "Death Indicator"}, {"file": "death_indicator.json","VSName": "Death Indicator"},
{"file": "identifier_type.json","VSName": "Identifier Type"}, {"file": "identifier_type.json","VSName": "Identifier Type"},
{"file": "operation.json","VSName": "Operation (CRUD)"}, {"file": "operation.json","VSName": "Operation (CRUD)"},
{"file": "event_id.json","VSName": "Audit Event ID"},
{"file": "did_type.json","VSName": "DID Type"}, {"file": "did_type.json","VSName": "DID Type"},
{"file": "requested_entity.json","VSName": "Requested Entity"}, {"file": "requested_entity.json","VSName": "Requested Entity"},
{"file": "order_priority.json","VSName": "Order Priority"}, {"file": "order_priority.json","VSName": "Order Priority"},

View File

@ -0,0 +1,79 @@
{
"name": "event_id",
"VSName": "Audit Event ID",
"VCategory": "System",
"values": [
{"key": "PATIENT_REGISTERED", "value": "Patient registered"},
{"key": "PATIENT_DEMOGRAPHICS_UPDATED", "value": "Patient demographics updated"},
{"key": "PATIENT_MERGED", "value": "Patient merged"},
{"key": "PATIENT_UNMERGED", "value": "Patient unmerged"},
{"key": "PATIENT_IDENTIFIER_UPDATED", "value": "Patient identifier updated"},
{"key": "PATIENT_CONSENT_UPDATED", "value": "Patient consent updated"},
{"key": "PATIENT_INSURANCE_UPDATED", "value": "Patient insurance updated"},
{"key": "PATIENT_DELETED", "value": "Patient deleted"},
{"key": "VISIT_ADMITTED", "value": "Visit admitted"},
{"key": "VISIT_TRANSFERRED", "value": "Visit transferred"},
{"key": "VISIT_DISCHARGED", "value": "Visit discharged"},
{"key": "VISIT_STATUS_UPDATED", "value": "Visit status updated"},
{"key": "ORDER_CREATED", "value": "Order created"},
{"key": "ORDER_CANCELLED", "value": "Order cancelled"},
{"key": "ORDER_REOPENED", "value": "Order reopened"},
{"key": "ORDER_TEST_ADDED", "value": "Order test added"},
{"key": "ORDER_TEST_REMOVED", "value": "Order test removed"},
{"key": "SPECIMEN_COLLECTED", "value": "Specimen collected"},
{"key": "SPECIMEN_RECEIVED", "value": "Specimen received"},
{"key": "SPECIMEN_REJECTED", "value": "Specimen rejected"},
{"key": "SPECIMEN_ALIQUOTED", "value": "Specimen aliquoted"},
{"key": "SPECIMEN_DISPOSED", "value": "Specimen disposed"},
{"key": "RESULT_ENTERED", "value": "Result entered"},
{"key": "RESULT_UPDATED", "value": "Result updated"},
{"key": "RESULT_VERIFIED", "value": "Result verified"},
{"key": "RESULT_AMENDED", "value": "Result amended"},
{"key": "RESULT_RELEASED", "value": "Result released"},
{"key": "RESULT_RETRACTED", "value": "Result retracted"},
{"key": "RESULT_CORRECTED", "value": "Result corrected"},
{"key": "QC_RECORDED", "value": "QC recorded"},
{"key": "QC_FAILED", "value": "QC failed"},
{"key": "QC_OVERRIDE_APPLIED", "value": "QC override applied"},
{"key": "VALUESET_ITEM_CREATED", "value": "Value set item created"},
{"key": "VALUESET_ITEM_UPDATED", "value": "Value set item updated"},
{"key": "VALUESET_ITEM_RETIRED", "value": "Value set item retired"},
{"key": "TEST_DEFINITION_UPDATED", "value": "Test definition updated"},
{"key": "REFERENCE_RANGE_UPDATED", "value": "Reference range updated"},
{"key": "TEST_PANEL_MEMBERSHIP_UPDATED", "value": "Test panel membership updated"},
{"key": "ANALYZER_CONFIG_UPDATED", "value": "Analyzer config updated"},
{"key": "INTEGRATION_CONFIG_UPDATED", "value": "Integration config updated"},
{"key": "CODING_SYSTEM_UPDATED", "value": "Coding system updated"},
{"key": "USER_CREATED", "value": "User created"},
{"key": "USER_DISABLED", "value": "User disabled"},
{"key": "USER_PASSWORD_RESET", "value": "User password reset"},
{"key": "USER_ROLE_CHANGED", "value": "User role changed"},
{"key": "USER_PERMISSION_CHANGED", "value": "User permission changed"},
{"key": "SITE_CREATED", "value": "Site created"},
{"key": "SITE_UPDATED", "value": "Site updated"},
{"key": "WORKSTATION_UPDATED", "value": "Workstation updated"},
{"key": "AUTH_LOGIN_SUCCESS", "value": "Auth login success"},
{"key": "AUTH_LOGOUT_SUCCESS", "value": "Auth logout success"},
{"key": "AUTH_LOGIN_FAILED", "value": "Auth login failed"},
{"key": "AUTH_LOCKOUT_TRIGGERED", "value": "Auth lockout triggered"},
{"key": "TOKEN_ISSUED", "value": "Token issued"},
{"key": "TOKEN_REFRESHED", "value": "Token refreshed"},
{"key": "TOKEN_REVOKED", "value": "Token revoked"},
{"key": "AUTHORIZATION_FAILED", "value": "Authorization failed"},
{"key": "IMPORT_JOB_STARTED", "value": "Import job started"},
{"key": "IMPORT_JOB_FINISHED", "value": "Import job finished"},
{"key": "EXPORT_JOB_STARTED", "value": "Export job started"},
{"key": "EXPORT_JOB_FINISHED", "value": "Export job finished"},
{"key": "JOB_STARTED", "value": "Job started"},
{"key": "JOB_FINISHED", "value": "Job finished"},
{"key": "INTEGRATION_SYNC_STARTED", "value": "Integration sync started"},
{"key": "INTEGRATION_SYNC_FINISHED", "value": "Integration sync finished"},
{"key": "AUDIT_WRITE_FAILED", "value": "Audit write failed"},
{"key": "AUDIT_ARCHIVE_EXECUTED", "value": "Audit archive executed"},
{"key": "AUDIT_PURGE_EXECUTED", "value": "Audit purge executed"},
{"key": "AUDIT_CHECKSUM_CREATED", "value": "Audit checksum created"},
{"key": "AUDIT_CHECKSUM_FAILED", "value": "Audit checksum failed"},
{"key": "LEGAL_HOLD_APPLIED", "value": "Legal hold applied"},
{"key": "LEGAL_HOLD_RELEASED", "value": "Legal hold released"}
]
}

View File

@ -152,16 +152,23 @@ class PatientModel extends BaseModel {
$newInternalPID = $this->getInsertID(); $newInternalPID = $this->getInsertID();
$this->checkDbError($db, 'Insert patient'); $this->checkDbError($db, 'Insert patient');
$auditDiff = $this->buildAuditDiff([], $input);
AuditService::logData( AuditService::logData(
'PATIENT_REGISTERED',
'CREATE', 'CREATE',
'patient', 'patient',
(string) $newInternalPID, (string) $newInternalPID,
'patient', 'patient',
null, null,
$previousData, null,
$input, null,
'Patient registration', 'Patient registration',
['PatientID' => $input['PatientID'] ?? null] [
'diff' => $auditDiff,
'patient_id' => $input['PatientID'] ?? null,
'validation_profile' => 'patient.create',
],
['entity_version' => 1]
); );
if (!empty($patIdt)) { if (!empty($patIdt)) {
@ -219,16 +226,23 @@ class PatientModel extends BaseModel {
$this->checkDbError($db, 'Update patient'); $this->checkDbError($db, 'Update patient');
$changedFields = array_keys(array_diff_assoc((array) $previousData, (array) $input)); $changedFields = array_keys(array_diff_assoc((array) $previousData, (array) $input));
$auditDiff = $this->buildAuditDiff((array) $previousData, $input);
AuditService::logData( AuditService::logData(
'PATIENT_DEMOGRAPHICS_UPDATED',
'UPDATE', 'UPDATE',
'patient', 'patient',
(string) $InternalPID, (string) $InternalPID,
'patient', 'patient',
null, null,
(array) $previousData, null,
$input, null,
'Patient data updated', 'Patient data updated',
['changed_fields' => $changedFields] [
'diff' => $auditDiff,
'changed_fields' => $changedFields,
'validation_profile' => 'patient.update',
],
['entity_version' => 1]
); );
if (!empty($input['PatIdt'])) { if (!empty($input['PatIdt'])) {
@ -349,6 +363,24 @@ class PatientModel extends BaseModel {
return $date->format('j M Y'); return $date->format('j M Y');
} }
private function buildAuditDiff(array $before, array $after): array {
$diff = [];
$fields = array_unique(array_merge(array_keys($before), array_keys($after)));
foreach ($fields as $field) {
$prev = $before[$field] ?? null;
$next = $after[$field] ?? null;
if ($prev === $next) {
continue;
}
$diff[] = [
'field' => $field,
'previous' => $prev,
'new' => $next,
];
}
return $diff;
}
private function checkDbError($db, string $context) { private function checkDbError($db, string $context) {
$error = $db->error(); $error = $db->error();
if (!empty($error['code'])) { if (!empty($error['code'])) {
@ -371,16 +403,22 @@ class PatientModel extends BaseModel {
$this->delete($InternalPID); $this->delete($InternalPID);
$this->checkDbError($db, 'Delete patient'); $this->checkDbError($db, 'Delete patient');
$auditDiff = $this->buildAuditDiff((array) $previousData, []);
AuditService::logData( AuditService::logData(
'PATIENT_DELETED',
'DELETE', 'DELETE',
'patient', 'patient',
(string) $InternalPID, (string) $InternalPID,
'patient', 'patient',
null, null,
$previousData, $previousData,
[], null,
'Patient deleted', 'Patient deleted',
['PatientID' => $previousData['PatientID'] ?? null] [
'diff' => $auditDiff,
'patient_id' => $previousData['PatientID'] ?? null,
],
['entity_version' => 1]
); );
$db->transCommit(); $db->transCommit();

View File

@ -2,208 +2,345 @@
namespace App\Services; namespace App\Services;
use App\Libraries\ValueSet;
use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\BaseConnection;
use CodeIgniter\HTTP\IncomingRequest;
use Config\Services;
use DateTime;
use DateTimeZone;
class AuditService { class AuditService
protected BaseConnection $db; {
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',
];
public function __construct() { private const DEFAULT_APP_ID = 'clqms-api';
$this->db = \Config\Database::connect(); 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( public static function logData(
string $operation, string $eventId,
string $activityId,
string $entityType, string $entityType,
string $entityId, string $entityId,
?string $tableName = null, string $tableName,
?string $fieldName = null, ?string $fldName = null,
?array $previousValue = null, $previousValue = null,
?array $newValue = null, $newValue = null,
?string $reason = null, ?string $reason = null,
?array $context = null ?array $context = null,
array $options = []
): void { ): void {
self::log('data_audit_log', [ $sourceTable = $tableName ?: $entityType;
'operation' => $operation, $logTable = self::resolveLogTable($sourceTable);
'entity_type' => $entityType, if ($logTable === null) {
'entity_id' => $entityId, log_message('warning', "AuditService cannot resolve log table for {$sourceTable}");
'table_name' => $tableName,
'field_name' => $fieldName,
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'MANUAL',
'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(),
'session_id' => self::getSessionId(),
'event_type' => strtoupper($entityType) . '_' . strtoupper($operation),
'site_id' => self::getSiteId(),
'workstation_id' => self::getWorkstationId(),
'pc_name' => self::getPcName(),
'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId(),
'reason' => $reason,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
public static function logService(
string $operation,
string $entityType,
string $entityId,
string $serviceClass,
?string $resourceType = null,
?array $resourceDetails = null,
?array $previousValue = null,
?array $newValue = null,
?string $serviceName = null,
?array $context = null
): void {
self::log('service_audit_log', [
'operation' => $operation,
'entity_type' => $entityType,
'entity_id' => $entityId,
'service_class' => $serviceClass,
'resource_type' => $resourceType,
'resource_details' => self::normalizeAuditValue($resourceDetails),
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'AUTOMATIC',
'application_id' => $serviceName ?? 'SYSTEM-SERVICE',
'service_name' => $serviceName,
'session_id' => self::getSessionId() ?: 'service_session',
'event_type' => strtoupper($serviceClass) . '_' . strtoupper($operation),
'site_id' => self::getSiteId(),
'workstation_id' => self::getWorkstationId(),
'pc_name' => self::getPcName(),
'ip_address' => self::getIpAddress(),
'port' => $resourceDetails['port'] ?? null,
'user_id' => 'SYSTEM',
'reason' => null,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
public static function logSecurity(
string $operation,
string $entityType,
string $entityId,
string $securityClass,
?string $eventType = 'SUCCESS',
?string $resourcePath = null,
?array $previousValue = null,
?array $newValue = null,
?string $reason = null,
?array $context = null
): void {
self::log('security_audit_log', [
'operation' => $operation,
'entity_type' => $entityType,
'entity_id' => $entityId,
'security_class' => $securityClass,
'resource_path' => $resourcePath,
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'MANUAL',
'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(),
'session_id' => self::getSessionId(),
'event_type' => $eventType,
'site_id' => self::getSiteId(),
'workstation_id' => self::getWorkstationId(),
'pc_name' => self::getPcName(),
'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId() ?? 'UNKNOWN',
'reason' => $reason,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
public static function logError(
string $entityType,
string $entityId,
string $errorCode,
string $errorMessage,
string $eventType,
?array $errorDetails = null,
?array $previousValue = null,
?array $newValue = null,
?string $reason = null,
?array $context = null
): void {
self::log('error_audit_log', [
'operation' => 'ERROR',
'entity_type' => $entityType,
'entity_id' => $entityId,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'error_details' => self::normalizeAuditValue($errorDetails),
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'AUTOMATIC',
'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(),
'session_id' => self::getSessionId() ?: 'system',
'event_type' => $eventType,
'site_id' => self::getSiteId(),
'workstation_id' => self::getWorkstationId(),
'pc_name' => self::getPcName(),
'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId() ?? 'SYSTEM',
'reason' => $reason,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
private static function log(string $table, array $data): void {
$db = \Config\Database::connect();
if (!$db->tableExists($table)) {
return; return;
} }
$db->table($table)->insert($data);
$record = self::buildRecord(
$logTable,
self::normalizeEventId($eventId),
strtoupper($activityId),
$entityType,
$entityId,
$sourceTable,
$fldName,
$previousValue,
$newValue,
$reason,
$context,
$options
);
if ($record === null) {
return;
} }
private static function normalizeAuditValue($value) 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 || is_scalar($value)) { if ($value === null) {
return $value; return null;
}
if (is_scalar($value)) {
return (string) $value;
} }
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $json !== false ? $json : null; return $json !== false ? $json : null;
} }
private static function getUri(): ?string { private static function buildContext(?array $context, array $options, string $entityType): ?string
return $_SERVER['REQUEST_URI'] ?? null; {
$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 getSessionId(): ?string { private static function resolveLogTable(?string $source): ?string
$session = session(); {
return $session->get('session_id'); if ($source === null) {
return null;
} }
private static function getSiteId(): ?string { $key = strtolower(trim($source));
$session = session(); return self::TABLE_MAP[$key] ?? null;
return $session->get('site_id');
} }
private static function getWorkstationId(): ?string { private static function resolveUserId(array $options): string
$session = session(); {
return $session->get('workstation_id'); return $options['user_id'] ?? self::getSessionValue('user_id') ?? 'SYSTEM';
} }
private static function getPcName(): ?string { private static function resolveSiteId(array $options): string
return gethostname(); {
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();
} }
private static function getIpAddress(): ?string {
return $_SERVER['REMOTE_ADDR'] ?? null; return $_SERVER['REMOTE_ADDR'] ?? null;
} }
private static function getUserId(): ?string { private static function normalizeEventId(string $eventId): string
$session = session(); {
return $session->get('user_id'); $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);
}
} }
} }

View File

@ -1,194 +1,352 @@
# Audit Logging Strategy # Audit Logging Strategy (Implementation Ready)
## Overview ## 1) Purpose, Scope, and Non-Goals
This document defines how CLQMS should capture audit and operational logs across four tables: This document defines the production audit logging contract for CLQMS.
- `logpatient` — patient, visit, and ADT activity ### Purpose
- `logorder` — orders, tests, specimens, results, and QC
- `logmaster` — master data and configuration changes
- `logsystem` — sessions, security, import/export, and system operations
The intent is to audit all domains, including master data changes, and to standardize event capture so reporting and compliance are consistent. - Provide a single, normalized audit model for compliance, investigations, and operations.
- Ensure every protected workflow writes consistent, queryable audit records.
- Make behavior deterministic across API controllers, services, jobs, and integrations.
## Table Ownership ### Scope
| Event | Table | This applies to four log tables:
| --- | --- |
| Patient registered/updated/merged | `logpatient` |
| Insurance/consent changed | `logpatient` |
| Patient visit (admit/transfer/discharge) | `logpatient` |
| Order created/cancelled | `logorder` |
| Sample received/rejected | `logorder` |
| Result entered/verified/amended | `logorder` |
| Result released/retracted/corrected | `logorder` |
| QC result recorded | `logorder` |
| Test panel added/removed | `logmaster` |
| Reference range changed | `logmaster` |
| Analyzer config updated | `logmaster` |
| User role changed | `logmaster` |
| User login/logout | `logsystem` |
| Import/export job start/end | `logsystem` |
## Standard Log Schema (Shared Columns) - `logpatient` - patient identity, demographics, consent, insurance, and visit/ADT events.
- `logorder` - orders, specimen lifecycle, results lifecycle, and QC.
- `logmaster` - test/master configuration, value sets, role/permission updates, infrastructure configuration.
- `logsystem` - authentication, authorization, import/export, jobs, and system integrity operations.
Use a shared schema for all four tables to keep instrumentation and reporting consistent. The legacy names below match existing patterns and can be reused. ### Non-goals
| Column | Description | - This is not a replacement for metrics/tracing systems (Prometheus, APM, etc.).
| --- | --- | - This is not a full immutable ledger; tamper evidence is implemented with controls described below.
| `LogID` (PK) | Auto increment primary key per table (e.g., `LogPatientID`) |
| `TblName` | Source table name |
| `RecID` | Record ID of the entity |
| `FldName` | Field name that changed (nullable for bulk events) |
| `FldValuePrev` | Previous value (string or JSON) |
| `FldValueNew` | New value (string or JSON) |
| `UserID` | Acting user ID (nullable for system actions) |
| `SiteID` | Site context |
| `DIDType` | Device identifier type |
| `DID` | Device identifier |
| `MachineID` | Workstation or host identifier |
| `SessionID` | Session identifier |
| `AppID` | Client application ID |
| `ProcessID` | Process/workflow identifier |
| `WebPageID` | UI page/context (nullable) |
| `EventID` | Event code (see catalog) |
| `ActivityID` | Action code (create/update/delete/read/etc.) |
| `Reason` | User/system reason |
| `LogDate` | Timestamp of event |
| `Context` | JSON metadata (optional but recommended) |
| `IpAddress` | Remote IP (optional but recommended) |
Recommended: keep a JSON string in `Context` for extra details (e.g., route, request id, batch id, error message). Use size limits to avoid oversized rows. ## 2) Table Ownership
## Event Catalog Use this mapping to choose the target table and minimum event shape.
### logpatient | Event family | Table | Minimum keys in `Context` | Example `EventID` |
| --- | --- | --- | --- |
| Patient create/update/merge | `logpatient` | `route`, `request_id`, `entity_version` | `PATIENT_REGISTERED` |
| Consent/insurance changes | `logpatient` | `consent_type` or `payer_id` | `PATIENT_CONSENT_UPDATED` |
| Visit ADT transitions | `logpatient` | `visit_id`, `from_status`, `to_status` | `VISIT_TRANSFERRED` |
| Order create/cancel/reopen | `logorder` | `order_id`, `priority`, `source` | `ORDER_CREATED` |
| Specimen lifecycle | `logorder` | `specimen_id`, `specimen_status` | `SPECIMEN_RECEIVED` |
| Result lifecycle | `logorder` | `result_id`, `verification_state` | `RESULT_AMENDED` |
| QC lifecycle | `logorder` | `qc_run_id`, `instrument_id` | `QC_RECORDED` |
| Value sets/test definitions | `logmaster` | `config_group`, `change_ticket` | `VALUESET_ITEM_RETIRED` |
| Roles/permissions/users | `logmaster` | `target_user_id`, `target_role` | `USER_ROLE_CHANGED` |
| Login/logout/token/auth failures | `logsystem` | `auth_flow`, `failure_reason` (on failure) | `AUTH_LOGIN_FAILED` |
| Import/export/jobs/integration | `logsystem` | `batch_id`, `record_count`, `job_name` | `IMPORT_JOB_FINISHED` |
| Purge/archive/legal hold | `logsystem` | `archive_id`, `policy_name`, `approved_by` | `AUDIT_PURGE_EXECUTED` |
**Patient core** ## 3) Canonical Schema (All Four Tables)
- Register patient All four tables MUST implement the same logical columns. Physical PK name may vary (`LogPatientID`, `LogOrderID`, etc.).
- Update demographics
- Merge/unmerge/split
- Identity changes (MRN, external identifiers)
- Consent grant/revoke/update
- Insurance add/update/remove
- Patient record view (if required by compliance)
**Visit/ADT** ### 3.1 Column contract
- Admit, transfer, discharge | Column | Type | Required | Max length | Description | Example |
- Bed/ward/unit changes | --- | --- | --- | --- | --- | --- |
- Visit status updates | `LogID` (or table-specific PK) | `BIGINT UNSIGNED AUTO_INCREMENT` | Yes | N/A | Surrogate key per table | `987654` |
| `TblName` | `VARCHAR(64)` | Yes | 64 | Source business table | `patient` |
| `RecID` | `VARCHAR(64)` | Yes | 64 | Primary identifier of affected entity | `PAT000123` |
| `FldName` | `VARCHAR(128)` | Conditional | 128 | Changed field name, null for multi-field/bulk | `NameLast` |
| `FldValuePrev` | `TEXT` | Conditional | 65535 | Previous value (string or JSON) | `{"status":"PENDING"}` |
| `FldValueNew` | `TEXT` | Conditional | 65535 | New value (string or JSON) | `{"status":"VERIFIED"}` |
| `UserID` | `VARCHAR(64)` | Yes | 64 | Actor user id, or `SYSTEM` for non-user actions | `USR001` |
| `SiteID` | `VARCHAR(32)` | Yes | 32 | Facility/site context | `SITE01` |
| `DIDType` | `VARCHAR(32)` | No | 32 | Device identifier type | `UUID` |
| `DID` | `VARCHAR(128)` | No | 128 | Device identifier value | `6b8f...` |
| `MachineID` | `VARCHAR(128)` | No | 128 | Host/workstation identifier | `WS-LAB-07` |
| `SessionID` | `VARCHAR(128)` | Yes | 128 | Auth or workflow session identifier | `sess_abc123` |
| `AppID` | `VARCHAR(64)` | Yes | 64 | Calling client/application id | `clqms-api` |
| `ProcessID` | `VARCHAR(128)` | No | 128 | Process/workflow/job id | `job_20260325_01` |
| `WebPageID` | `VARCHAR(128)` | No | 128 | UI route/page id if user-driven | `patient-detail` |
| `EventID` | `VARCHAR(80)` | Yes | 80 | Canonical event code | `RESULT_RELEASED` |
| `ActivityID` | `VARCHAR(24)` | Yes | 24 | Canonical action enum | `UPDATE` |
| `Reason` | `VARCHAR(512)` | No | 512 | User/system reason or ticket reference | `Critical value corrected` |
| `LogDate` | `DATETIME(3)` | Yes | N/A | Event time in UTC | `2026-03-25 04:45:12.551` |
| `Context` | `JSON` (preferred) or `LONGTEXT` | Yes | N/A | Structured metadata payload | See section 5 |
| `IpAddress` | `VARCHAR(45)` | No | 45 | IPv4/IPv6 remote address | `10.10.2.44` |
**Other** ### 3.2 Required/conditional rules
- Patient notes/attachments added/removed - `FldName`, `FldValuePrev`, and `FldValueNew` are required for single-field changes.
- Patient alerts/flags changes - For multi-field changes, set `FldName = NULL` and store a compact JSON diff under `Context.diff`.
- For non-mutating events (`READ`, `LOGIN`, `EXPORT`, `IMPORT`), `FldValuePrev` and `FldValueNew` may be null.
- `Context` is required for all rows. At minimum include `request_id` and `route` (or `job_name` for non-HTTP jobs).
### logorder ## 4) DDL Template and Indexing
**Orders/tests** Use this template when creating a log table. Replace `${TABLE}` and `${PK}`.
- Create/cancel/reopen order ```sql
- Add/remove tests CREATE TABLE `${TABLE}` (
- Priority changes `${PK}` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
- Order comments added/removed `TblName` VARCHAR(64) NOT NULL,
`RecID` VARCHAR(64) NOT NULL,
`FldName` VARCHAR(128) NULL,
`FldValuePrev` TEXT NULL,
`FldValueNew` TEXT NULL,
`UserID` VARCHAR(64) NOT NULL,
`SiteID` VARCHAR(32) NOT NULL,
`DIDType` VARCHAR(32) NULL,
`DID` VARCHAR(128) NULL,
`MachineID` VARCHAR(128) NULL,
`SessionID` VARCHAR(128) NOT NULL,
`AppID` VARCHAR(64) NOT NULL,
`ProcessID` VARCHAR(128) NULL,
`WebPageID` VARCHAR(128) NULL,
`EventID` VARCHAR(80) NOT NULL,
`ActivityID` VARCHAR(24) NOT NULL,
`Reason` VARCHAR(512) NULL,
`LogDate` DATETIME(3) NOT NULL,
`Context` JSON NOT NULL,
`IpAddress` VARCHAR(45) NULL,
PRIMARY KEY (`${PK}`),
INDEX `idx_${TABLE}_logdate` (`LogDate`),
INDEX `idx_${TABLE}_recid_logdate` (`RecID`, `LogDate`),
INDEX `idx_${TABLE}_userid_logdate` (`UserID`, `LogDate`),
INDEX `idx_${TABLE}_eventid_logdate` (`EventID`, `LogDate`),
INDEX `idx_${TABLE}_site_logdate` (`SiteID`, `LogDate`)
);
```
**Specimen lifecycle** Optional JSON path index (DB engine specific):
- Collected, labeled, received, rejected - `Context.request_id`
- Centrifuged, aliquoted, stored - `Context.batch_id`
- Disposed/expired - `Context.job_name`
**Results** ## 5) Context JSON Contract
- Result entered/updated `Context` MUST be valid JSON. Keep payload compact and predictable.
- Verified/amended
- Released/retracted/corrected
- Result comments/interpretation changes
- Auto-verification override
**QC** ### 5.1 Required keys for all events
- QC result recorded ```json
- QC failure/override {
"request_id": "a4f5b6c7",
"route": "PATCH /api/patient/123",
"timestamp_utc": "2026-03-25T04:45:12.551Z",
"entity_type": "patient",
"entity_version": 7
}
```
### logmaster ### 5.2 Additional keys by event class
**Value sets** - Patient/order/result mutation: `diff` (array of changed fields), `validation_profile`.
- Import/export/jobs: `batch_id`, `record_count`, `success_count`, `failure_count`, `job_name`.
- Auth/security events: `auth_flow`, `failure_reason`, `token_type` (never token value).
- Retention operations: `policy_name`, `archive_id`, `approved_by`, `window_start`, `window_end`.
- Create/update/retire value set items ### 5.3 Size and shape limits
**Test definitions** - Maximum serialized `Context` size: 16 KB.
- `diff` array should include only audited fields, not entire entity snapshots.
- Store references (`file_id`, `blob_ref`) instead of large payloads.
- Test definition updates (units, methods, ranges) ## 6) Activity and Event Catalog Governance
- Reference range changes
- Formula/delta check changes
- Test panel membership add/remove
**Infrastructure** `EventID` values MUST come from the ValueSet library, not hardcoded inline strings.
- Analyzer/instrument config changes - Source file: `app/Libraries/Data/event_id.json`
- Host app integration config - Runtime access: `\App\Libraries\ValueSet::getRaw('event_id')`
- Coding system changes - Optional label lookup for reporting: `\App\Libraries\ValueSet::getLabel('event_id', $eventId)`
**Users/roles** ### 6.1 Allowed `ActivityID`
- User create/disable/reset `CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`, `LOCK`, `UNLOCK`, `RESET`
- Role changes
- Permission changes
**Sites/workstations** ### 6.2 `EventID` naming pattern
- Site/location/workstation CRUD - Format: `<DOMAIN>_<OBJECT>_<ACTION>`
- Character set: uppercase A-Z, numbers, underscore.
- Max length: 80.
- Examples: `PATIENT_DEMOGRAPHICS_UPDATED`, `ORDER_CANCELLED`, `AUTH_LOGIN_FAILED`.
### logsystem ### 6.3 Catalog lifecycle
**Sessions & security** - New `EventID` requires docs update and test coverage.
- New `EventID` must be added to `app/Libraries/Data/event_id.json` and deployed with cache refresh (`ValueSet::clearCache()`).
- Never repurpose an existing `EventID` to mean something else.
- Deprecated `EventID` remains queryable and documented for historical data.
- Login/logout ## 7) Minimum Event Coverage (Must Implement)
- Failed login attempts
- Lockouts/password resets
- Token issue/refresh/revoke
- Authorization failures
**Import/export** ### 7.1 `logpatient`
- Import/export job start/end - `PATIENT_REGISTERED`, `PATIENT_DEMOGRAPHICS_UPDATED`, `PATIENT_MERGED`, `PATIENT_UNMERGED`
- Batch ID, source, record counts, status - `PATIENT_IDENTIFIER_UPDATED`, `PATIENT_CONSENT_UPDATED`, `PATIENT_INSURANCE_UPDATED`
- `VISIT_ADMITTED`, `VISIT_TRANSFERRED`, `VISIT_DISCHARGED`, `VISIT_STATUS_UPDATED`
**System operations** ### 7.2 `logorder`
- Background jobs start/end - `ORDER_CREATED`, `ORDER_CANCELLED`, `ORDER_REOPENED`, `ORDER_TEST_ADDED`, `ORDER_TEST_REMOVED`
- Integration sync runs - `SPECIMEN_COLLECTED`, `SPECIMEN_RECEIVED`, `SPECIMEN_REJECTED`, `SPECIMEN_ALIQUOTED`, `SPECIMEN_DISPOSED`
- System config changes - `RESULT_ENTERED`, `RESULT_UPDATED`, `RESULT_VERIFIED`, `RESULT_AMENDED`, `RESULT_RELEASED`, `RESULT_RETRACTED`, `RESULT_CORRECTED`
- Service errors that affect data integrity - `QC_RECORDED`, `QC_FAILED`, `QC_OVERRIDE_APPLIED`
## Activity & Event Codes ### 7.3 `logmaster`
Use consistent `ActivityID` and `EventID` values. Recommended defaults: - `VALUESET_ITEM_CREATED`, `VALUESET_ITEM_UPDATED`, `VALUESET_ITEM_RETIRED`
- `TEST_DEFINITION_UPDATED`, `REFERENCE_RANGE_UPDATED`, `TEST_PANEL_MEMBERSHIP_UPDATED`
- `ANALYZER_CONFIG_UPDATED`, `INTEGRATION_CONFIG_UPDATED`, `CODING_SYSTEM_UPDATED`
- `USER_CREATED`, `USER_DISABLED`, `USER_PASSWORD_RESET`, `USER_ROLE_CHANGED`, `USER_PERMISSION_CHANGED`
- `SITE_CREATED`, `SITE_UPDATED`, `WORKSTATION_UPDATED`
- `ActivityID`: `CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT` ### 7.4 `logsystem`
- `EventID`: domain-specific codes (e.g., `PATIENT_REGISTERED`, `ORDER_CREATED`, `RESULT_VERIFIED`, `QC_RECORDED`)
## Capture Guidelines - `AUTH_LOGIN_SUCCESS`, `AUTH_LOGOUT_SUCCESS`, `AUTH_LOGIN_FAILED`, `AUTH_LOCKOUT_TRIGGERED`
- `TOKEN_ISSUED`, `TOKEN_REFRESHED`, `TOKEN_REVOKED`, `AUTHORIZATION_FAILED`
- `IMPORT_JOB_STARTED`, `IMPORT_JOB_FINISHED`, `EXPORT_JOB_STARTED`, `EXPORT_JOB_FINISHED`
- `JOB_STARTED`, `JOB_FINISHED`, `INTEGRATION_SYNC_STARTED`, `INTEGRATION_SYNC_FINISHED`
- `AUDIT_ARCHIVE_EXECUTED`, `AUDIT_PURGE_EXECUTED`, `LEGAL_HOLD_APPLIED`, `LEGAL_HOLD_RELEASED`
- Always capture `UserID`, `SessionID`, `SiteID`, and `LogDate` when available. ## 8) Capture Rules (Application Behavior)
- If the action is system-driven, set `UserID` to `SYSTEM` (or null) and add context in `Context`.
- Store payload diffs in `FldValuePrev` and `FldValueNew` for single-field changes; for multi-field changes, put a JSON diff in `Context` and leave `FldName` null.
- For bulk operations, store batch metadata in `Context` (`batch_id`, `record_count`, `source`).
- Do not log secrets, tokens, or full PHI when not required. Mask or omit sensitive fields.
## Retention & Governance ### 8.1 Write timing
- Define retention policy per table (e.g., 7 years for patient/order, 2 years for system). - For mutating transactions, write audit record in the same DB transaction where feasible.
- Archive before purge; record purge activity in `logsystem`. - If asynchronous logging is required, enqueue within transaction and process with at-least-once delivery.
- Restrict write/delete permissions to service accounts only.
## Implementation Checklist ### 8.2 Failure policy
1. Create the four tables with shared schema (or migrate existing log tables to match). - Compliance-critical writes (patient, order, result, role/permission): fail request if audit write fails.
2. Add a single audit service with helpers to build a normalized payload. - Operational-only writes (non-critical job checkpoints): continue request, emit error log, retry in background.
3. Instrument controllers/services for each event category above. - All audit write failures must produce `logsystem` event `AUDIT_WRITE_FAILED` with sanitized details.
4. Add automated tests for representative audit writes.
5. Document `EventID` codes used by each endpoint/service. ### 8.3 Diff policy
- Single-field change: set `FldName`, `FldValuePrev`, `FldValueNew`.
- Multi-field change: set `FldName = NULL`, keep prev/new null or compact summary, place canonical diff in `Context.diff`.
- Bulk operations: include `batch_id`, `record_count`, sample `affected_ids` (capped), and source.
## 9) Security and Privacy Controls
### 9.1 Never log
- Passwords, raw JWTs, API secrets, private keys, OTP values.
- Full clinical free text unless explicitly required by policy.
### 9.2 Masking rules
- Identifiers with high sensitivity should be masked in `FldValuePrev/New` when not required.
- Token-like strings should be fully removed and replaced with `[REDACTED]`.
- Use deterministic masking where correlation is needed (e.g., hash + prefix).
### 9.3 Access control
- Insert permissions only for API/service accounts.
- No update/delete privileges for regular runtime users.
- Read access to logs is role-restricted and audited.
### 9.4 Tamper evidence
- Enable DB audit on DDL changes to log tables.
- Store periodic checksum snapshots of recent log ranges in secure storage.
- Record checksum run outcomes in `logsystem` (`AUDIT_CHECKSUM_CREATED`, `AUDIT_CHECKSUM_FAILED`).
## 10) Retention, Archive, and Purge
### 10.1 Default retention
- `logpatient`: 7 years
- `logorder`: 7 years
- `logmaster`: 5 years
- `logsystem`: 2 years
If regional policy requires longer periods, policy overrides these defaults.
### 10.2 Archive workflow
1. Select eligible rows by `LogDate` and legal-hold status.
2. Export to immutable archive format (compressed JSONL or parquet).
3. Verify checksums and row counts.
4. Write `AUDIT_ARCHIVE_EXECUTED` entry in `logsystem`.
### 10.3 Purge workflow
1. Require approval reference (`approved_by`, `change_ticket`).
2. Purge archived rows only.
3. Write `AUDIT_PURGE_EXECUTED` entry with table, date window, count, and archive reference.
## 11) Operational Monitoring
Track these SLIs/SLOs:
- Audit write success rate >= 99.9% for critical domains.
- P95 audit insert latency < 50 ms.
- Queue backlog age < 5 minutes (if async path is used).
- Zero unreviewed `AUDIT_WRITE_FAILED` older than 24 hours.
Alert on:
- Sustained write failures.
- Sudden drop in expected event volume.
- Purge/archive jobs without corresponding `logsystem` records.
## 12) Migration Strategy for Existing Logs
1. Inventory current columns and event vocabulary in all four tables.
2. Add missing canonical columns with nullable defaults.
3. Backfill required values (`AppID`, `SessionID`, `Context` minimum keys) where derivable.
4. Introduce canonical `EventID` mapping table for legacy names.
5. Enforce NOT NULL constraints only after backfill validation succeeds.
## 13) Testing Requirements
### 13.1 Automated tests
- Feature tests for representative endpoints must assert audit row creation.
- Assert table target, `ActivityID`, `EventID`, `RecID`, and required `Context` keys.
- Assert `EventID` exists in `\App\Libraries\ValueSet::getRaw('event_id')`.
- Add negative tests for audit failure policy (critical path blocks, non-critical path retries).
### 13.2 Test matrix minimum
- One success and one failure scenario per major domain (`patient`, `order`, `master`, `system`).
- One bulk operation scenario validating `batch_id` and counts.
- One security scenario validating redaction of sensitive fields.
## 14) Implementation Checklist (Phased)
### Phase 1 - Schema and constants
1. Create/align all four log tables to canonical schema.
2. Add shared enums/constants for `ActivityID` and `EventID`.
3. Add and maintain `app/Libraries/Data/event_id.json` as the `EventID` source of truth.
4. Add DB indexes listed in section 4.
### Phase 2 - Audit service
1. Implement centralized audit writer service.
2. Add helpers to normalize actor/device/session/context.
3. Add diff builder utility for single and multi-field changes.
### Phase 3 - Instrumentation
1. Instrument patient and order flows first (compliance-critical).
2. Instrument master and system flows.
3. Add fallback/retry path and `AUDIT_WRITE_FAILED` emission.
### Phase 4 - Validation and rollout
1. Add feature tests and failure-path tests.
2. Validate dashboards/queries for each table.
3. Release with runbook updates and retention job schedule.
## 15) Acceptance Criteria
The implementation is complete when all statements below are true:
- Every protected endpoint emits at least one canonical audit row.
- Each row has valid `ActivityID`, `EventID` (present in ValueSet `event_id`), `LogDate` (UTC), and non-empty `Context` with required keys.
- Sensitive values are redacted/masked per section 9.
- Archive and purge operations are fully traceable in `logsystem`.
- Tests cover critical success/failure paths and pass in CI.

View File

@ -4447,16 +4447,55 @@ paths:
properties: properties:
status: status:
type: string type: string
message:
type: string
data: data:
type: array type: array
items: items:
$ref: '#/components/schemas/TestDefinition' $ref: '#/components/schemas/TestDefinitionListItem'
pagination: pagination:
type: object type: object
properties: properties:
total: total:
type: integer type: integer
description: Total number of records matching the query description: Total number of records matching the query
examples:
list_flat:
summary: Flat list response from testdefsite
value:
status: success
message: Data fetched successfully
data:
- TestSiteID: 21
TestSiteCode: GLU
TestSiteName: Glucose
TestType: TEST
SeqScr: 11
SeqRpt: 11
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
StartDate: '2026-01-01 00:00:00'
EndDate: null
DisciplineID: 2
DepartmentID: 2
DisciplineName: Clinical Chemistry
DepartmentName: Laboratory
- TestSiteID: 22
TestSiteCode: CREA
TestSiteName: Creatinine
TestType: TEST
SeqScr: 12
SeqRpt: 12
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
StartDate: '2026-01-01 00:00:00'
EndDate: null
DisciplineID: 2
DepartmentID: 2
DisciplineName: Clinical Chemistry
DepartmentName: Laboratory
post: post:
tags: tags:
- Test - Test
@ -4546,62 +4585,26 @@ paths:
type: integer type: integer
CountStat: CountStat:
type: integer type: integer
details: testdefcal:
type: object type: object
description: | description: Calculated test metadata persisted in the `testdefcal` table.
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties: properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
RefType:
type: string
enum:
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
FormulaCode: FormulaCode:
type: string type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
Unit1: testdefgrp:
type: string type: object
Factor: description: Group member payload stored in the `testdefgrp` table.
type: number properties:
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members: members:
type: array type: array
description: | description: Array of member TestSiteIDs for CALC/GROUP definitions.
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items: items:
type: object type: object
properties: properties:
TestSiteID: TestSiteID:
type: integer type: integer
description: The actual TestSiteID of the member test (required) description: Foreign key referencing the member test's TestSiteID.
required: required:
- TestSiteID - TestSiteID
refnum: refnum:
@ -4634,7 +4637,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
Unit1: mg/dL Unit1: mg/dL
@ -4651,7 +4653,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 0 VisibleRpt: 0
CountStat: 0 CountStat: 0
details:
DisciplineID: 10 DisciplineID: 10
DepartmentID: 0 DepartmentID: 0
Unit1: cm Unit1: cm
@ -4668,13 +4669,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
refnum: refnum:
- NumRefType: NMRC - NumRefType: NMRC
RangeType: REF RangeType: REF
@ -4686,6 +4680,12 @@ paths:
AgeStart: 18 AgeStart: 18
AgeEnd: 99 AgeEnd: 99
Flag: 'N' Flag: 'N'
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
TEST_range_multiple_map: TEST_range_multiple_map:
summary: Numeric reference with multiple ranges and test map summary: Numeric reference with multiple ranges and test map
value: value:
@ -4698,13 +4698,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
refnum: refnum:
- NumRefType: NMRC - NumRefType: NMRC
RangeType: REF RangeType: REF
@ -4752,6 +4745,12 @@ paths:
ConDefID: 3 ConDefID: 3
ClientTestCode: HB_C ClientTestCode: HB_C
ClientTestName: Hemoglobin Client ClientTestName: Hemoglobin Client
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
TEST_threshold: TEST_threshold:
summary: Technical test with threshold reference summary: Technical test with threshold reference
value: value:
@ -4764,13 +4763,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
refnum: refnum:
- NumRefType: THOLD - NumRefType: THOLD
RangeType: PANIC RangeType: PANIC
@ -4780,6 +4772,12 @@ paths:
AgeStart: 0 AgeStart: 0
AgeEnd: 125 AgeEnd: 125
Flag: H Flag: H
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
TEST_threshold_map: TEST_threshold_map:
summary: Threshold reference plus test map summary: Threshold reference plus test map
value: value:
@ -4792,13 +4790,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
refnum: refnum:
- NumRefType: THOLD - NumRefType: THOLD
RangeType: PANIC RangeType: PANIC
@ -4832,6 +4823,12 @@ paths:
ConDefID: 1 ConDefID: 1
ClientTestCode: GLU_C ClientTestCode: GLU_C
ClientTestName: Glucose Client ClientTestName: Glucose Client
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
TEST_text: TEST_text:
summary: Technical test with text reference summary: Technical test with text reference
value: value:
@ -4844,12 +4841,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
Method: Morphology
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: TEXT TxtRefType: TEXT
@ -4858,6 +4849,11 @@ paths:
AgeEnd: 99 AgeEnd: 99
RefTxt: NORM=Normal;HIGH=High RefTxt: NORM=Normal;HIGH=High
Flag: 'N' Flag: 'N'
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
Method: Morphology
TEST_text_map: TEST_text_map:
summary: Text reference plus test map summary: Text reference plus test map
value: value:
@ -4870,11 +4866,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: TEXT TxtRefType: TEXT
@ -4901,6 +4892,10 @@ paths:
ConDefID: 4 ConDefID: 4
ClientTestCode: STAGE_C ClientTestCode: STAGE_C
ClientTestName: Disease Stage Client ClientTestName: Disease Stage Client
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
TEST_valueset: TEST_valueset:
summary: Technical test using a value set result summary: Technical test using a value set result
value: value:
@ -4913,12 +4908,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
Method: Visual
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: VSET TxtRefType: VSET
@ -4927,6 +4916,11 @@ paths:
AgeEnd: 120 AgeEnd: 120
RefTxt: NORM=Normal;MACRO=Macro RefTxt: NORM=Normal;MACRO=Macro
Flag: 'N' Flag: 'N'
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
Method: Visual
TEST_valueset_map: TEST_valueset_map:
summary: Value set reference with test map summary: Value set reference with test map
value: value:
@ -4939,11 +4933,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: VSET TxtRefType: VSET
@ -4963,6 +4952,10 @@ paths:
ConDefID: 12 ConDefID: 12
ClientTestCode: UCOLOR_C ClientTestCode: UCOLOR_C
ClientTestName: Urine Color Client ClientTestName: Urine Color Client
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
TEST_valueset_map_no_reftxt: TEST_valueset_map_no_reftxt:
summary: Value set result with mapping but without explicit text reference entries summary: Value set result with mapping but without explicit text reference entries
value: value:
@ -4975,11 +4968,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
testmap: testmap:
- HostType: SITE - HostType: SITE
HostID: '1' HostID: '1'
@ -4991,6 +4979,10 @@ paths:
ConDefID: 12 ConDefID: 12
ClientTestCode: UGLUC_C ClientTestCode: UGLUC_C
ClientTestName: Urine Glucose Client ClientTestName: Urine Glucose Client
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
CALC_basic: CALC_basic:
summary: Calculated test with members (no references) summary: Calculated test with members (no references)
value: value:
@ -5003,10 +4995,11 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 0 CountStat: 0
details:
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
testdefcal:
FormulaCode: CKD_EPI(CREA,AGE,GENDER) FormulaCode: CKD_EPI(CREA,AGE,GENDER)
testdefgrp:
members: members:
- TestSiteID: 21 - TestSiteID: 21
- TestSiteID: 22 - TestSiteID: 22
@ -5022,13 +5015,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 0 CountStat: 0
details:
DisciplineID: 2
DepartmentID: 2
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
members:
- TestSiteID: 21
- TestSiteID: 22
refnum: refnum:
- NumRefType: NMRC - NumRefType: NMRC
RangeType: REF RangeType: REF
@ -5051,6 +5037,14 @@ paths:
ConDefID: 1 ConDefID: 1
ClientTestCode: EGFR_C ClientTestCode: EGFR_C
ClientTestName: eGFR Client ClientTestName: eGFR Client
DisciplineID: 2
DepartmentID: 2
testdefcal:
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
testdefgrp:
members:
- TestSiteID: 21
- TestSiteID: 22
GROUP_with_members: GROUP_with_members:
summary: Group/profile test with members and mapping summary: Group/profile test with members and mapping
value: value:
@ -5063,10 +5057,6 @@ paths:
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
members:
- TestSiteID: 169
- TestSiteID: 170
testmap: testmap:
- HostType: SITE - HostType: SITE
HostID: '1' HostID: '1'
@ -5078,6 +5068,10 @@ paths:
ConDefID: 1 ConDefID: 1
ClientTestCode: LIPID_C ClientTestCode: LIPID_C
ClientTestName: Lipid Client ClientTestName: Lipid Client
testdefgrp:
members:
- TestSiteID: 169
- TestSiteID: 170
responses: responses:
'201': '201':
description: Test definition created description: Test definition created
@ -5195,62 +5189,26 @@ paths:
type: integer type: integer
CountStat: CountStat:
type: integer type: integer
details: testdefcal:
type: object type: object
description: | description: Calculated test metadata persisted in the `testdefcal` table.
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties: properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
RefType:
type: string
enum:
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
FormulaCode: FormulaCode:
type: string type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
Unit1: testdefgrp:
type: string type: object
Factor: description: Group member payload stored in the `testdefgrp` table.
type: number properties:
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members: members:
type: array type: array
description: | description: Array of member TestSiteIDs for CALC/GROUP definitions.
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items: items:
type: object type: object
properties: properties:
TestSiteID: TestSiteID:
type: integer type: integer
description: The actual TestSiteID of the member test (required) description: Foreign key referencing the member test's TestSiteID.
required: required:
- TestSiteID - TestSiteID
refnum: refnum:
@ -8122,6 +8080,58 @@ components:
type: string type: string
format: date-time format: date-time
description: Soft delete timestamp description: Soft delete timestamp
TestDefinitionListItem:
type: object
properties:
TestSiteID:
type: integer
TestSiteCode:
type: string
TestSiteName:
type: string
TestType:
type: string
enum:
- TEST
- PARAM
- CALC
- GROUP
- TITLE
SeqScr:
type: integer
SeqRpt:
type: integer
VisibleScr:
type: integer
enum:
- 0
- 1
VisibleRpt:
type: integer
enum:
- 0
- 1
CountStat:
type: integer
StartDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
nullable: true
DisciplineID:
type: integer
nullable: true
DepartmentID:
type: integer
nullable: true
DisciplineName:
type: string
nullable: true
DepartmentName:
type: string
nullable: true
ValueSetListItem: ValueSetListItem:
type: object type: object
description: Library/system value set summary (from JSON files) description: Library/system value set summary (from JSON files)

View File

@ -1,3 +1,47 @@
TestDefinitionListItem:
type: object
properties:
TestSiteID:
type: integer
TestSiteCode:
type: string
TestSiteName:
type: string
TestType:
type: string
enum: [TEST, PARAM, CALC, GROUP, TITLE]
SeqScr:
type: integer
SeqRpt:
type: integer
VisibleScr:
type: integer
enum: [0, 1]
VisibleRpt:
type: integer
enum: [0, 1]
CountStat:
type: integer
StartDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
nullable: true
DisciplineID:
type: integer
nullable: true
DepartmentID:
type: integer
nullable: true
DisciplineName:
type: string
nullable: true
DepartmentName:
type: string
nullable: true
TestDefinition: TestDefinition:
type: object type: object
properties: properties:

View File

@ -55,16 +55,55 @@
properties: properties:
status: status:
type: string type: string
message:
type: string
data: data:
type: array type: array
items: items:
$ref: '../components/schemas/tests.yaml#/TestDefinition' $ref: '../components/schemas/tests.yaml#/TestDefinitionListItem'
pagination: pagination:
type: object type: object
properties: properties:
total: total:
type: integer type: integer
description: Total number of records matching the query description: Total number of records matching the query
examples:
list_flat:
summary: Flat list response from testdefsite
value:
status: success
message: Data fetched successfully
data:
- TestSiteID: 21
TestSiteCode: GLU
TestSiteName: Glucose
TestType: TEST
SeqScr: 11
SeqRpt: 11
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
StartDate: '2026-01-01 00:00:00'
EndDate: null
DisciplineID: 2
DepartmentID: 2
DisciplineName: Clinical Chemistry
DepartmentName: Laboratory
- TestSiteID: 22
TestSiteCode: CREA
TestSiteName: Creatinine
TestType: TEST
SeqScr: 12
SeqRpt: 12
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
StartDate: '2026-01-01 00:00:00'
EndDate: null
DisciplineID: 2
DepartmentID: 2
DisciplineName: Clinical Chemistry
DepartmentName: Laboratory
post: post:
tags: [Test] tags: [Test]
@ -139,52 +178,26 @@
type: integer type: integer
CountStat: CountStat:
type: integer type: integer
details: testdefcal:
type: object type: object
description: | description: Calculated test metadata persisted in the `testdefcal` table.
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties: properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
RefType:
type: string
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
FormulaCode: FormulaCode:
type: string type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
Unit1: testdefgrp:
type: string type: object
Factor: description: Group member payload stored in the `testdefgrp` table.
type: number properties:
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members: members:
type: array type: array
description: | description: Array of member TestSiteIDs for CALC/GROUP definitions.
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items: items:
type: object type: object
properties: properties:
TestSiteID: TestSiteID:
type: integer type: integer
description: The actual TestSiteID of the member test (required) description: Foreign key referencing the member test's TestSiteID.
required: required:
- TestSiteID - TestSiteID
refnum: refnum:
@ -217,7 +230,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
Unit1: mg/dL Unit1: mg/dL
@ -234,7 +246,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 0 VisibleRpt: 0
CountStat: 0 CountStat: 0
details:
DisciplineID: 10 DisciplineID: 10
DepartmentID: 0 DepartmentID: 0
Unit1: cm Unit1: cm
@ -251,13 +262,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
refnum: refnum:
- NumRefType: NMRC - NumRefType: NMRC
RangeType: REF RangeType: REF
@ -269,6 +273,12 @@
AgeStart: 18 AgeStart: 18
AgeEnd: 99 AgeEnd: 99
Flag: N Flag: N
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
TEST_range_multiple_map: TEST_range_multiple_map:
summary: Numeric reference with multiple ranges and test map summary: Numeric reference with multiple ranges and test map
value: value:
@ -281,13 +291,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
refnum: refnum:
- NumRefType: NMRC - NumRefType: NMRC
RangeType: REF RangeType: REF
@ -304,7 +307,7 @@
Sex: '1' Sex: '1'
LowSign: '>' LowSign: '>'
Low: 75 Low: 75
HighSign: '<' HighSign: <
High: 105 High: 105
AgeStart: 18 AgeStart: 18
AgeEnd: 99 AgeEnd: 99
@ -335,6 +338,12 @@
ConDefID: 3 ConDefID: 3
ClientTestCode: HB_C ClientTestCode: HB_C
ClientTestName: Hemoglobin Client ClientTestName: Hemoglobin Client
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
TEST_threshold: TEST_threshold:
summary: Technical test with threshold reference summary: Technical test with threshold reference
value: value:
@ -347,13 +356,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
refnum: refnum:
- NumRefType: THOLD - NumRefType: THOLD
RangeType: PANIC RangeType: PANIC
@ -363,6 +365,12 @@
AgeStart: 0 AgeStart: 0
AgeEnd: 125 AgeEnd: 125
Flag: H Flag: H
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
TEST_threshold_map: TEST_threshold_map:
summary: Threshold reference plus test map summary: Threshold reference plus test map
value: value:
@ -375,13 +383,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
refnum: refnum:
- NumRefType: THOLD - NumRefType: THOLD
RangeType: PANIC RangeType: PANIC
@ -394,7 +395,7 @@
- NumRefType: THOLD - NumRefType: THOLD
RangeType: PANIC RangeType: PANIC
Sex: '1' Sex: '1'
LowSign: '<' LowSign: <
Low: 121 Low: 121
AgeStart: 0 AgeStart: 0
AgeEnd: 125 AgeEnd: 125
@ -415,6 +416,12 @@
ConDefID: 1 ConDefID: 1
ClientTestCode: GLU_C ClientTestCode: GLU_C
ClientTestName: Glucose Client ClientTestName: Glucose Client
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
TEST_text: TEST_text:
summary: Technical test with text reference summary: Technical test with text reference
value: value:
@ -427,20 +434,19 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
Method: Morphology
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: TEXT TxtRefType: TEXT
Sex: '2' Sex: '2'
AgeStart: 18 AgeStart: 18
AgeEnd: 99 AgeEnd: 99
RefTxt: 'NORM=Normal;HIGH=High' RefTxt: NORM=Normal;HIGH=High
Flag: N Flag: N
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
Method: Morphology
TEST_text_map: TEST_text_map:
summary: Text reference plus test map summary: Text reference plus test map
value: value:
@ -453,25 +459,20 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: TEXT TxtRefType: TEXT
Sex: '2' Sex: '2'
AgeStart: 18 AgeStart: 18
AgeEnd: 99 AgeEnd: 99
RefTxt: 'NORM=Normal' RefTxt: NORM=Normal
Flag: N Flag: N
- SpcType: GEN - SpcType: GEN
TxtRefType: TEXT TxtRefType: TEXT
Sex: '1' Sex: '1'
AgeStart: 18 AgeStart: 18
AgeEnd: 99 AgeEnd: 99
RefTxt: 'ABN=Abnormal' RefTxt: ABN=Abnormal
Flag: N Flag: N
testmap: testmap:
- HostType: SITE - HostType: SITE
@ -484,6 +485,10 @@
ConDefID: 4 ConDefID: 4
ClientTestCode: STAGE_C ClientTestCode: STAGE_C
ClientTestName: Disease Stage Client ClientTestName: Disease Stage Client
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
TEST_valueset: TEST_valueset:
summary: Technical test using a value set result summary: Technical test using a value set result
value: value:
@ -496,20 +501,19 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
Method: Visual
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: VSET TxtRefType: VSET
Sex: '2' Sex: '2'
AgeStart: 0 AgeStart: 0
AgeEnd: 120 AgeEnd: 120
RefTxt: 'NORM=Normal;MACRO=Macro' RefTxt: NORM=Normal;MACRO=Macro
Flag: N Flag: N
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
Method: Visual
TEST_valueset_map: TEST_valueset_map:
summary: Value set reference with test map summary: Value set reference with test map
value: value:
@ -522,18 +526,13 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
reftxt: reftxt:
- SpcType: GEN - SpcType: GEN
TxtRefType: VSET TxtRefType: VSET
Sex: '2' Sex: '2'
AgeStart: 0 AgeStart: 0
AgeEnd: 120 AgeEnd: 120
RefTxt: 'NORM=Normal;ABN=Abnormal' RefTxt: NORM=Normal;ABN=Abnormal
Flag: N Flag: N
testmap: testmap:
- HostType: SITE - HostType: SITE
@ -546,6 +545,10 @@
ConDefID: 12 ConDefID: 12
ClientTestCode: UCOLOR_C ClientTestCode: UCOLOR_C
ClientTestName: Urine Color Client ClientTestName: Urine Color Client
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
TEST_valueset_map_no_reftxt: TEST_valueset_map_no_reftxt:
summary: Value set result with mapping but without explicit text reference entries summary: Value set result with mapping but without explicit text reference entries
value: value:
@ -558,11 +561,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
testmap: testmap:
- HostType: SITE - HostType: SITE
HostID: '1' HostID: '1'
@ -574,6 +572,10 @@
ConDefID: 12 ConDefID: 12
ClientTestCode: UGLUC_C ClientTestCode: UGLUC_C
ClientTestName: Urine Glucose Client ClientTestName: Urine Glucose Client
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
CALC_basic: CALC_basic:
summary: Calculated test with members (no references) summary: Calculated test with members (no references)
value: value:
@ -586,10 +588,11 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 0 CountStat: 0
details:
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
testdefcal:
FormulaCode: CKD_EPI(CREA,AGE,GENDER) FormulaCode: CKD_EPI(CREA,AGE,GENDER)
testdefgrp:
members: members:
- TestSiteID: 21 - TestSiteID: 21
- TestSiteID: 22 - TestSiteID: 22
@ -605,13 +608,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 0 CountStat: 0
details:
DisciplineID: 2
DepartmentID: 2
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
members:
- TestSiteID: 21
- TestSiteID: 22
refnum: refnum:
- NumRefType: NMRC - NumRefType: NMRC
RangeType: REF RangeType: REF
@ -634,6 +630,14 @@
ConDefID: 1 ConDefID: 1
ClientTestCode: EGFR_C ClientTestCode: EGFR_C
ClientTestName: eGFR Client ClientTestName: eGFR Client
DisciplineID: 2
DepartmentID: 2
testdefcal:
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
testdefgrp:
members:
- TestSiteID: 21
- TestSiteID: 22
GROUP_with_members: GROUP_with_members:
summary: Group/profile test with members and mapping summary: Group/profile test with members and mapping
value: value:
@ -646,10 +650,6 @@
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 1 CountStat: 1
details:
members:
- TestSiteID: 169
- TestSiteID: 170
testmap: testmap:
- HostType: SITE - HostType: SITE
HostID: '1' HostID: '1'
@ -661,6 +661,11 @@
ConDefID: 1 ConDefID: 1
ClientTestCode: LIPID_C ClientTestCode: LIPID_C
ClientTestName: Lipid Client ClientTestName: Lipid Client
testdefgrp:
members:
- TestSiteID: 169
- TestSiteID: 170
responses: responses:
'201': '201':
description: Test definition created description: Test definition created
@ -763,52 +768,26 @@
type: integer type: integer
CountStat: CountStat:
type: integer type: integer
details: testdefcal:
type: object type: object
description: | description: Calculated test metadata persisted in the `testdefcal` table.
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties: properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
RefType:
type: string
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
FormulaCode: FormulaCode:
type: string type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
Unit1: testdefgrp:
type: string type: object
Factor: description: Group member payload stored in the `testdefgrp` table.
type: number properties:
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members: members:
type: array type: array
description: | description: Array of member TestSiteIDs for CALC/GROUP definitions.
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items: items:
type: object type: object
properties: properties:
TestSiteID: TestSiteID:
type: integer type: integer
description: The actual TestSiteID of the member test (required) description: Foreign key referencing the member test's TestSiteID.
required: required:
- TestSiteID - TestSiteID
refnum: refnum: