From 7600989bed9b1fe5bcad6e2e526f8494499033ea Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Wed, 25 Mar 2026 10:41:22 +0700 Subject: [PATCH] feat: expand audit logging service and docs --- .cocoindex_code/cocoindex.db/mdb/data.mdb | Bin 5824512 -> 5824512 bytes .cocoindex_code/target_sqlite.db | Bin 9424896 -> 9424896 bytes .../2026-03-25-000050_CreateAuditLogs.php | 137 +++++ app/Libraries/Data/_meta.json | 3 +- app/Libraries/Data/event_id.json | 79 +++ app/Models/Patient/PatientModel.php | 126 ++-- app/Services/AuditService.php | 547 +++++++++++------- docs/audit-logging.md | 438 +++++++++----- public/api-docs.bundled.yaml | 350 +++++------ public/components/schemas/tests.yaml | 46 +- public/paths/tests.yaml | 375 ++++++------ 11 files changed, 1342 insertions(+), 759 deletions(-) create mode 100644 app/Database/Migrations/2026-03-25-000050_CreateAuditLogs.php create mode 100644 app/Libraries/Data/event_id.json diff --git a/.cocoindex_code/cocoindex.db/mdb/data.mdb b/.cocoindex_code/cocoindex.db/mdb/data.mdb index fe49e3507e883bddfef1cc908efa5115f5a5d0d5..d984184ef555fb8534295c16d942eeb421a86d59 100644 GIT binary patch delta 37662 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 %u5#S}7#4TUb&nJyVS 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 delta 33402 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þIV3p===@~aMPMHfS* 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 X0yMQJX(?M{dzyZ1p '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); + } +} diff --git a/app/Libraries/Data/_meta.json b/app/Libraries/Data/_meta.json index 94d432c..3ebdb22 100644 --- a/app/Libraries/Data/_meta.json +++ b/app/Libraries/Data/_meta.json @@ -9,7 +9,8 @@ {"file": "marital_status.json","VSName": "Marital Status"}, {"file": "death_indicator.json","VSName": "Death Indicator"}, {"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": "requested_entity.json","VSName": "Requested Entity"}, {"file": "order_priority.json","VSName": "Order Priority"}, diff --git a/app/Libraries/Data/event_id.json b/app/Libraries/Data/event_id.json new file mode 100644 index 0000000..a6b7f10 --- /dev/null +++ b/app/Libraries/Data/event_id.json @@ -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"} + ] +} diff --git a/app/Models/Patient/PatientModel.php b/app/Models/Patient/PatientModel.php index 3753b9e..3f267ac 100644 --- a/app/Models/Patient/PatientModel.php +++ b/app/Models/Patient/PatientModel.php @@ -152,17 +152,24 @@ class PatientModel extends BaseModel { $newInternalPID = $this->getInsertID(); $this->checkDbError($db, 'Insert patient'); - AuditService::logData( - 'CREATE', - 'patient', - (string) $newInternalPID, - 'patient', - null, - $previousData, - $input, - 'Patient registration', - ['PatientID' => $input['PatientID'] ?? null] - ); + $auditDiff = $this->buildAuditDiff([], $input); + AuditService::logData( + 'PATIENT_REGISTERED', + 'CREATE', + 'patient', + (string) $newInternalPID, + 'patient', + null, + null, + null, + 'Patient registration', + [ + 'diff' => $auditDiff, + 'patient_id' => $input['PatientID'] ?? null, + 'validation_profile' => 'patient.create', + ], + ['entity_version' => 1] + ); if (!empty($patIdt)) { $modelPatIdt->createPatIdt($patIdt, $newInternalPID); @@ -219,16 +226,23 @@ class PatientModel extends BaseModel { $this->checkDbError($db, 'Update patient'); $changedFields = array_keys(array_diff_assoc((array) $previousData, (array) $input)); + $auditDiff = $this->buildAuditDiff((array) $previousData, $input); AuditService::logData( + 'PATIENT_DEMOGRAPHICS_UPDATED', 'UPDATE', 'patient', (string) $InternalPID, 'patient', null, - (array) $previousData, - $input, + null, + null, 'Patient data updated', - ['changed_fields' => $changedFields] + [ + 'diff' => $auditDiff, + 'changed_fields' => $changedFields, + 'validation_profile' => 'patient.update', + ], + ['entity_version' => 1] ); if (!empty($input['PatIdt'])) { @@ -335,25 +349,43 @@ class PatientModel extends BaseModel { } } - private function formatedDateForDisplay($dateString) { - $date = \DateTime::createFromFormat('Y-m-d H:i', $dateString); - - if (!$date) { - $timestamp = strtotime($dateString); - if ($timestamp) { - return date('j M Y', $timestamp); - } - return null; - } - - return $date->format('j M Y'); - } - - private function checkDbError($db, string $context) { - $error = $db->error(); - if (!empty($error['code'])) { - throw new \Exception( - "{$context} failed: {$error['code']} - {$error['message']}" + private function formatedDateForDisplay($dateString) { + $date = \DateTime::createFromFormat('Y-m-d H:i', $dateString); + + if (!$date) { + $timestamp = strtotime($dateString); + if ($timestamp) { + return date('j M Y', $timestamp); + } + return null; + } + + 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) { + $error = $db->error(); + if (!empty($error['code'])) { + throw new \Exception( + "{$context} failed: {$error['code']} - {$error['message']}" ); } } @@ -371,17 +403,23 @@ class PatientModel extends BaseModel { $this->delete($InternalPID); $this->checkDbError($db, 'Delete patient'); - AuditService::logData( - 'DELETE', - 'patient', - (string) $InternalPID, - 'patient', - null, - $previousData, - [], - 'Patient deleted', - ['PatientID' => $previousData['PatientID'] ?? null] - ); + $auditDiff = $this->buildAuditDiff((array) $previousData, []); + AuditService::logData( + 'PATIENT_DELETED', + 'DELETE', + 'patient', + (string) $InternalPID, + 'patient', + null, + $previousData, + null, + 'Patient deleted', + [ + 'diff' => $auditDiff, + 'patient_id' => $previousData['PatientID'] ?? null, + ], + ['entity_version' => 1] + ); $db->transCommit(); return $InternalPID; diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php index 334b715..cab8c3c 100644 --- a/app/Services/AuditService.php +++ b/app/Services/AuditService.php @@ -1,209 +1,346 @@ -db = \Config\Database::connect(); - } - + 'logpatient', + 'patient' => 'logpatient', + 'visit' => 'logpatient', + 'logorder' => 'logorder', + 'order' => 'logorder', + 'specimen' => 'logorder', + 'result' => 'logorder', + 'logmaster' => 'logmaster', + 'master' => 'logmaster', + 'config' => 'logmaster', + 'valueset' => 'logmaster', + 'logsystem' => 'logsystem', + 'system' => 'logsystem', + 'auth' => 'logsystem', + 'job' => 'logsystem', + ]; + + private const DEFAULT_APP_ID = 'clqms-api'; + private const ENTITY_VERSION_DEFAULT = 1; + + private static ?BaseConnection $db = null; + private static $session = null; + private static ?IncomingRequest $request = null; + private static ?array $eventIdCache = null; + private static ?string $cachedRequestId = null; + public static function logData( - string $operation, - string $entityType, - string $entityId, - ?string $tableName = null, - ?string $fieldName = null, - ?array $previousValue = null, - ?array $newValue = null, - ?string $reason = null, - ?array $context = null - ): void { - self::log('data_audit_log', [ - 'operation' => $operation, - 'entity_type' => $entityType, - 'entity_id' => $entityId, - '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; + string $eventId, + string $activityId, + string $entityType, + string $entityId, + string $tableName, + ?string $fldName = null, + $previousValue = null, + $newValue = null, + ?string $reason = null, + ?array $context = null, + array $options = [] + ): void { + $sourceTable = $tableName ?: $entityType; + $logTable = self::resolveLogTable($sourceTable); + if ($logTable === null) { + log_message('warning', "AuditService cannot resolve log table for {$sourceTable}"); + return; + } + + $record = self::buildRecord( + $logTable, + self::normalizeEventId($eventId), + strtoupper($activityId), + $entityType, + $entityId, + $sourceTable, + $fldName, + $previousValue, + $newValue, + $reason, + $context, + $options + ); + + if ($record === null) { + return; + } + + try { + self::getDb()->table($logTable)->insert($record); + } catch (\Throwable $e) { + log_message('error', "AuditService failed to insert into {$logTable}: {$e->getMessage()}"); + } } - $db->table($table)->insert($data); - } - private static function normalizeAuditValue($value) - { - if ($value === null || is_scalar($value)) { - return $value; - } + 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; + } - $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - return $json !== false ? $json : null; - } - - private static function getUri(): ?string { - return $_SERVER['REQUEST_URI'] ?? null; - } - - private static function getSessionId(): ?string { - $session = session(); - return $session->get('session_id'); - } - - private static function getSiteId(): ?string { - $session = session(); - return $session->get('site_id'); - } - - private static function getWorkstationId(): ?string { - $session = session(); - return $session->get('workstation_id'); - } - - private static function getPcName(): ?string { - return gethostname(); - } - - private static function getIpAddress(): ?string { - return $_SERVER['REMOTE_ADDR'] ?? null; - } - - private static function getUserId(): ?string { - $session = session(); - return $session->get('user_id'); - } -} + return [ + 'TblName' => $tblName, + 'RecID' => (string) $entityId, + 'FldName' => $fldName, + 'FldValuePrev' => self::serializeValue($previousValue), + 'FldValueNew' => self::serializeValue($newValue), + 'UserID' => self::resolveUserId($options), + 'SiteID' => self::resolveSiteId($options), + 'DIDType' => $options['did_type'] ?? null, + 'DID' => $options['did'] ?? null, + 'MachineID' => $options['machine_id'] ?? gethostname(), + 'SessionID' => self::resolveSessionId($options), + 'AppID' => $options['app_id'] ?? self::DEFAULT_APP_ID, + 'ProcessID' => $options['process_id'] ?? null, + 'WebPageID' => $options['web_page_id'] ?? self::resolveRoute($options), + 'EventID' => $eventId, + 'ActivityID' => $activityId, + 'Reason' => $reason, + 'LogDate' => self::nowWithMillis(), + 'Context' => $contextJson, + 'IpAddress' => self::resolveIpAddress(), + ]; + } + + private static function serializeValue($value): ?string + { + if ($value === null) { + return null; + } + + if (is_scalar($value)) { + return (string) $value; + } + + $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return $json !== false ? $json : null; + } + + private static function buildContext(?array $context, array $options, string $entityType): ?string + { + $route = $options['route'] ?? self::resolveRoute($options); + $payload = array_merge( + [ + 'request_id' => $options['request_id'] ?? self::resolveRequestId(), + 'route' => $route, + 'timestamp_utc' => $options['timestamp_utc'] ?? self::timestampUtc(), + 'entity_type' => $options['entity_type'] ?? $entityType, + 'entity_version' => $options['entity_version'] ?? self::ENTITY_VERSION_DEFAULT, + ], + $context ?? [] + ); + + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return $json !== false ? $json : null; + } + + private static function resolveLogTable(?string $source): ?string + { + if ($source === null) { + return null; + } + + $key = strtolower(trim($source)); + return self::TABLE_MAP[$key] ?? null; + } + + private static function resolveUserId(array $options): string + { + return $options['user_id'] ?? self::getSessionValue('user_id') ?? 'SYSTEM'; + } + + private static function resolveSiteId(array $options): string + { + return $options['site_id'] ?? self::getSessionValue('site_id') ?? 'GLOBAL'; + } + + private static function resolveSessionId(array $options): string + { + if (!empty($options['session_id'])) { + return $options['session_id']; + } + + $session = self::getSession(); + if ($session !== null && method_exists($session, 'getId')) { + $id = $session->getId(); + if (!empty($id)) { + return $id; + } + } + + if (session_status() === PHP_SESSION_ACTIVE) { + $id = session_id(); + if (!empty($id)) { + return $id; + } + } + + return self::generateUniqueId('sess'); + } + + private static function resolveRoute(array $options): string + { + if (!empty($options['route'])) { + return $options['route']; + } + + $request = self::getRequest(); + if ($request !== null) { + return trim(sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath())); + } + + return 'cli'; + } + + private static function resolveIpAddress(): ?string + { + $request = self::getRequest(); + if ($request !== null) { + return $request->getIPAddress(); + } + + return $_SERVER['REMOTE_ADDR'] ?? null; + } + + private static function normalizeEventId(string $eventId): string + { + $normalized = strtoupper(trim($eventId)); + if (empty($normalized)) { + log_message('warning', 'AuditService received empty EventID'); + return $eventId; + } + + if (!self::isKnownEvent($normalized)) { + log_message('warning', "AuditService unknown EventID: {$normalized}"); + } + + return $normalized; + } + + private static function isKnownEvent(string $eventId): bool + { + if (self::$eventIdCache === null) { + $raw = ValueSet::getRaw('event_id') ?? []; + self::$eventIdCache = array_filter(array_map(fn ($item) => $item['key'] ?? null, $raw)); + } + + return in_array($eventId, self::$eventIdCache, true); + } + + private static function resolveRequestId(): string + { + if (self::$cachedRequestId !== null) { + return self::$cachedRequestId; + } + + $request = self::getRequest(); + if ($request !== null) { + $value = $request->getHeaderLine('X-Request-ID'); + if (!empty($value)) { + self::$cachedRequestId = $value; + return $value; + } + } + + foreach (['HTTP_X_REQUEST_ID', 'REQUEST_ID'] as $header) { + if (!empty($_SERVER[$header])) { + self::$cachedRequestId = $_SERVER[$header]; + return self::$cachedRequestId; + } + } + + return self::$cachedRequestId = self::generateUniqueId('req'); + } + + private static function nowWithMillis(): string + { + $dt = new DateTime('now', new DateTimeZone('UTC')); + return $dt->format('Y-m-d H:i:s.v'); + } + + private static function timestampUtc(): string + { + $dt = new DateTime('now', new DateTimeZone('UTC')); + return $dt->format('Y-m-d\TH:i:s.v\Z'); + } + + private static function getSessionValue(string $key): ?string + { + $session = self::getSession(); + if ($session === null) { + return null; + } + + if (!method_exists($session, 'get')) { + return null; + } + + $value = $session->get($key); + return $value !== null ? (string) $value : null; + } + + private static function getSession(): ?object + { + if (self::$session !== null) { + return self::$session; + } + + try { + return self::$session = Services::session(); + } catch (\Throwable $e) { + return self::$session = null; + } + } + + private static function getRequest(): ?IncomingRequest + { + if (self::$request !== null) { + return self::$request; + } + + try { + return self::$request = Services::request(); + } catch (\Throwable $e) { + return self::$request = null; + } + } + + private static function getDb(): BaseConnection + { + return self::$db ??= \Config\Database::connect(); + } + + private static function generateUniqueId(string $prefix): string + { + try { + return $prefix . '_' . bin2hex(random_bytes(8)); + } catch (\Throwable $e) { + return uniqid("{$prefix}_", true); + } + } +} diff --git a/docs/audit-logging.md b/docs/audit-logging.md index 26cfdf8..162de27 100644 --- a/docs/audit-logging.md +++ b/docs/audit-logging.md @@ -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 -- `logorder` — orders, tests, specimens, results, and QC -- `logmaster` — master data and configuration changes -- `logsystem` — sessions, security, import/export, and system operations +### Purpose -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 | -| --- | --- | -| 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` | +This applies to four log tables: -## 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 | -| --- | --- | -| `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) | +- 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. -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 -- 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) +All four tables MUST implement the same logical columns. Physical PK name may vary (`LogPatientID`, `LogOrderID`, etc.). -**Visit/ADT** +### 3.1 Column contract -- Admit, transfer, discharge -- Bed/ward/unit changes -- Visit status updates +| Column | Type | Required | Max length | Description | Example | +| --- | --- | --- | --- | --- | --- | +| `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 -- Patient alerts/flags changes +- `FldName`, `FldValuePrev`, and `FldValueNew` are required for single-field 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 -- Add/remove tests -- Priority changes -- Order comments added/removed +```sql +CREATE TABLE `${TABLE}` ( + `${PK}` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `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 -- Centrifuged, aliquoted, stored -- Disposed/expired +- `Context.request_id` +- `Context.batch_id` +- `Context.job_name` -**Results** +## 5) Context JSON Contract -- Result entered/updated -- Verified/amended -- Released/retracted/corrected -- Result comments/interpretation changes -- Auto-verification override +`Context` MUST be valid JSON. Keep payload compact and predictable. -**QC** +### 5.1 Required keys for all events -- QC result recorded -- QC failure/override +```json +{ + "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) -- Reference range changes -- Formula/delta check changes -- Test panel membership add/remove +## 6) Activity and Event Catalog Governance -**Infrastructure** +`EventID` values MUST come from the ValueSet library, not hardcoded inline strings. -- Analyzer/instrument config changes -- Host app integration config -- Coding system changes +- Source file: `app/Libraries/Data/event_id.json` +- Runtime access: `\App\Libraries\ValueSet::getRaw('event_id')` +- Optional label lookup for reporting: `\App\Libraries\ValueSet::getLabel('event_id', $eventId)` -**Users/roles** +### 6.1 Allowed `ActivityID` -- User create/disable/reset -- Role changes -- Permission changes +`CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`, `LOCK`, `UNLOCK`, `RESET` -**Sites/workstations** +### 6.2 `EventID` naming pattern -- Site/location/workstation CRUD +- Format: `__` +- 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 -- Failed login attempts -- Lockouts/password resets -- Token issue/refresh/revoke -- Authorization failures +## 7) Minimum Event Coverage (Must Implement) -**Import/export** +### 7.1 `logpatient` -- Import/export job start/end -- Batch ID, source, record counts, status +- `PATIENT_REGISTERED`, `PATIENT_DEMOGRAPHICS_UPDATED`, `PATIENT_MERGED`, `PATIENT_UNMERGED` +- `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 -- Integration sync runs -- System config changes -- Service errors that affect data integrity +- `ORDER_CREATED`, `ORDER_CANCELLED`, `ORDER_REOPENED`, `ORDER_TEST_ADDED`, `ORDER_TEST_REMOVED` +- `SPECIMEN_COLLECTED`, `SPECIMEN_RECEIVED`, `SPECIMEN_REJECTED`, `SPECIMEN_ALIQUOTED`, `SPECIMEN_DISPOSED` +- `RESULT_ENTERED`, `RESULT_UPDATED`, `RESULT_VERIFIED`, `RESULT_AMENDED`, `RESULT_RELEASED`, `RESULT_RETRACTED`, `RESULT_CORRECTED` +- `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` -- `EventID`: domain-specific codes (e.g., `PATIENT_REGISTERED`, `ORDER_CREATED`, `RESULT_VERIFIED`, `QC_RECORDED`) +### 7.4 `logsystem` -## 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. -- 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. +## 8) Capture Rules (Application Behavior) -## Retention & Governance +### 8.1 Write timing -- Define retention policy per table (e.g., 7 years for patient/order, 2 years for system). -- Archive before purge; record purge activity in `logsystem`. -- Restrict write/delete permissions to service accounts only. +- For mutating transactions, write audit record in the same DB transaction where feasible. +- If asynchronous logging is required, enqueue within transaction and process with at-least-once delivery. -## Implementation Checklist +### 8.2 Failure policy -1. Create the four tables with shared schema (or migrate existing log tables to match). -2. Add a single audit service with helpers to build a normalized payload. -3. Instrument controllers/services for each event category above. -4. Add automated tests for representative audit writes. -5. Document `EventID` codes used by each endpoint/service. +- Compliance-critical writes (patient, order, result, role/permission): fail request if audit write fails. +- Operational-only writes (non-critical job checkpoints): continue request, emit error log, retry in background. +- All audit write failures must produce `logsystem` event `AUDIT_WRITE_FAILED` with sanitized details. + +### 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. diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 3ce3e13..5e573c8 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -4447,16 +4447,55 @@ paths: properties: status: type: string + message: + type: string data: type: array items: - $ref: '#/components/schemas/TestDefinition' + $ref: '#/components/schemas/TestDefinitionListItem' pagination: type: object properties: total: type: integer 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: tags: - Test @@ -4546,62 +4585,26 @@ paths: type: integer CountStat: type: integer - details: + testdefcal: type: object - description: | - 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. + description: Calculated test metadata persisted in the `testdefcal` table. 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: type: string - description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") - Unit1: - type: string - Factor: - type: number - Unit2: - type: string - Decimal: - type: integer - default: 2 - Method: - type: string - ExpectedTAT: - type: integer + description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}") + testdefgrp: + type: object + description: Group member payload stored in the `testdefgrp` table. + properties: members: type: array - description: | - 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. + description: Array of member TestSiteIDs for CALC/GROUP definitions. items: type: object properties: TestSiteID: type: integer - description: The actual TestSiteID of the member test (required) + description: Foreign key referencing the member test's TestSiteID. required: - TestSiteID refnum: @@ -4634,11 +4637,10 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - Unit1: mg/dL - Method: CBC Analyzer + DisciplineID: 2 + DepartmentID: 2 + Unit1: mg/dL + Method: CBC Analyzer PARAM_no_ref: summary: Parameter without reference or map value: @@ -4651,11 +4653,10 @@ paths: VisibleScr: 1 VisibleRpt: 0 CountStat: 0 - details: - DisciplineID: 10 - DepartmentID: 0 - Unit1: cm - Method: Manual entry + DisciplineID: 10 + DepartmentID: 0 + Unit1: cm + Method: Manual entry TEST_range_single: summary: Technical test with numeric range reference (single) value: @@ -4668,13 +4669,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: RANGE - Unit1: mg/dL - Method: Hexokinase refnum: - NumRefType: NMRC RangeType: REF @@ -4686,6 +4680,12 @@ paths: AgeStart: 18 AgeEnd: 99 Flag: 'N' + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: RANGE + Unit1: mg/dL + Method: Hexokinase TEST_range_multiple_map: summary: Numeric reference with multiple ranges and test map value: @@ -4698,13 +4698,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: RANGE - Unit1: mg/dL - Method: Hexokinase refnum: - NumRefType: NMRC RangeType: REF @@ -4752,6 +4745,12 @@ paths: ConDefID: 3 ClientTestCode: HB_C ClientTestName: Hemoglobin Client + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: RANGE + Unit1: mg/dL + Method: Hexokinase TEST_threshold: summary: Technical test with threshold reference value: @@ -4764,13 +4763,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: THOLD - Unit1: mmol/L - Method: Auto Analyzer refnum: - NumRefType: THOLD RangeType: PANIC @@ -4780,6 +4772,12 @@ paths: AgeStart: 0 AgeEnd: 125 Flag: H + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: THOLD + Unit1: mmol/L + Method: Auto Analyzer TEST_threshold_map: summary: Threshold reference plus test map value: @@ -4792,13 +4790,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: THOLD - Unit1: mmol/L - Method: Auto Analyzer refnum: - NumRefType: THOLD RangeType: PANIC @@ -4832,6 +4823,12 @@ paths: ConDefID: 1 ClientTestCode: GLU_C ClientTestName: Glucose Client + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: THOLD + Unit1: mmol/L + Method: Auto Analyzer TEST_text: summary: Technical test with text reference value: @@ -4844,12 +4841,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 1 - DepartmentID: 1 - ResultType: TEXT - RefType: TEXT - Method: Morphology reftxt: - SpcType: GEN TxtRefType: TEXT @@ -4858,6 +4849,11 @@ paths: AgeEnd: 99 RefTxt: NORM=Normal;HIGH=High Flag: 'N' + DisciplineID: 1 + DepartmentID: 1 + ResultType: TEXT + RefType: TEXT + Method: Morphology TEST_text_map: summary: Text reference plus test map value: @@ -4870,11 +4866,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 1 - DepartmentID: 1 - ResultType: TEXT - RefType: TEXT reftxt: - SpcType: GEN TxtRefType: TEXT @@ -4901,6 +4892,10 @@ paths: ConDefID: 4 ClientTestCode: STAGE_C ClientTestName: Disease Stage Client + DisciplineID: 1 + DepartmentID: 1 + ResultType: TEXT + RefType: TEXT TEST_valueset: summary: Technical test using a value set result value: @@ -4913,12 +4908,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 4 - DepartmentID: 4 - ResultType: VSET - RefType: VSET - Method: Visual reftxt: - SpcType: GEN TxtRefType: VSET @@ -4927,6 +4916,11 @@ paths: AgeEnd: 120 RefTxt: NORM=Normal;MACRO=Macro Flag: 'N' + DisciplineID: 4 + DepartmentID: 4 + ResultType: VSET + RefType: VSET + Method: Visual TEST_valueset_map: summary: Value set reference with test map value: @@ -4939,11 +4933,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 4 - DepartmentID: 4 - ResultType: VSET - RefType: VSET reftxt: - SpcType: GEN TxtRefType: VSET @@ -4963,6 +4952,10 @@ paths: ConDefID: 12 ClientTestCode: UCOLOR_C ClientTestName: Urine Color Client + DisciplineID: 4 + DepartmentID: 4 + ResultType: VSET + RefType: VSET TEST_valueset_map_no_reftxt: summary: Value set result with mapping but without explicit text reference entries value: @@ -4975,11 +4968,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 4 - DepartmentID: 4 - ResultType: VSET - RefType: VSET testmap: - HostType: SITE HostID: '1' @@ -4991,6 +4979,10 @@ paths: ConDefID: 12 ClientTestCode: UGLUC_C ClientTestName: Urine Glucose Client + DisciplineID: 4 + DepartmentID: 4 + ResultType: VSET + RefType: VSET CALC_basic: summary: Calculated test with members (no references) value: @@ -5003,10 +4995,11 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 0 - details: - DisciplineID: 2 - DepartmentID: 2 + DisciplineID: 2 + DepartmentID: 2 + testdefcal: FormulaCode: CKD_EPI(CREA,AGE,GENDER) + testdefgrp: members: - TestSiteID: 21 - TestSiteID: 22 @@ -5022,13 +5015,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 0 - details: - DisciplineID: 2 - DepartmentID: 2 - FormulaCode: CKD_EPI(CREA,AGE,GENDER) - members: - - TestSiteID: 21 - - TestSiteID: 22 refnum: - NumRefType: NMRC RangeType: REF @@ -5051,6 +5037,14 @@ paths: ConDefID: 1 ClientTestCode: EGFR_C ClientTestName: eGFR Client + DisciplineID: 2 + DepartmentID: 2 + testdefcal: + FormulaCode: CKD_EPI(CREA,AGE,GENDER) + testdefgrp: + members: + - TestSiteID: 21 + - TestSiteID: 22 GROUP_with_members: summary: Group/profile test with members and mapping value: @@ -5063,10 +5057,6 @@ paths: VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - members: - - TestSiteID: 169 - - TestSiteID: 170 testmap: - HostType: SITE HostID: '1' @@ -5078,6 +5068,10 @@ paths: ConDefID: 1 ClientTestCode: LIPID_C ClientTestName: Lipid Client + testdefgrp: + members: + - TestSiteID: 169 + - TestSiteID: 170 responses: '201': description: Test definition created @@ -5195,62 +5189,26 @@ paths: type: integer CountStat: type: integer - details: + testdefcal: type: object - description: | - 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. + description: Calculated test metadata persisted in the `testdefcal` table. 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: type: string - description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") - Unit1: - type: string - Factor: - type: number - Unit2: - type: string - Decimal: - type: integer - default: 2 - Method: - type: string - ExpectedTAT: - type: integer + description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}") + testdefgrp: + type: object + description: Group member payload stored in the `testdefgrp` table. + properties: members: type: array - description: | - 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. + description: Array of member TestSiteIDs for CALC/GROUP definitions. items: type: object properties: TestSiteID: type: integer - description: The actual TestSiteID of the member test (required) + description: Foreign key referencing the member test's TestSiteID. required: - TestSiteID refnum: @@ -8122,6 +8080,58 @@ components: type: string format: date-time 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: type: object description: Library/system value set summary (from JSON files) diff --git a/public/components/schemas/tests.yaml b/public/components/schemas/tests.yaml index 630956e..6710bfb 100644 --- a/public/components/schemas/tests.yaml +++ b/public/components/schemas/tests.yaml @@ -1,4 +1,48 @@ -TestDefinition: +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: type: object properties: TestSiteID: diff --git a/public/paths/tests.yaml b/public/paths/tests.yaml index 2e0eeea..59c0605 100644 --- a/public/paths/tests.yaml +++ b/public/paths/tests.yaml @@ -53,18 +53,57 @@ schema: type: object properties: - status: - type: string - data: - type: array - items: - $ref: '../components/schemas/tests.yaml#/TestDefinition' - pagination: - type: object - properties: - total: - type: integer - description: Total number of records matching the query + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '../components/schemas/tests.yaml#/TestDefinitionListItem' + pagination: + type: object + properties: + total: + type: integer + 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: tags: [Test] @@ -137,56 +176,30 @@ type: integer VisibleRpt: type: integer - CountStat: - type: integer - details: - type: object - description: | - 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: - 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: - type: string - description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") - Unit1: - type: string - Factor: - type: number - Unit2: - type: string - Decimal: - type: integer - default: 2 - Method: - type: string - ExpectedTAT: - type: integer - members: - type: array - description: | - 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: - type: object - properties: - TestSiteID: - type: integer - description: The actual TestSiteID of the member test (required) - required: - - TestSiteID + CountStat: + type: integer + testdefcal: + type: object + description: Calculated test metadata persisted in the `testdefcal` table. + properties: + FormulaCode: + type: string + description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}") + testdefgrp: + type: object + description: Group member payload stored in the `testdefgrp` table. + properties: + members: + type: array + description: Array of member TestSiteIDs for CALC/GROUP definitions. + items: + type: object + properties: + TestSiteID: + type: integer + description: Foreign key referencing the member test's TestSiteID. + required: + - TestSiteID refnum: type: array items: @@ -217,11 +230,10 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - Unit1: mg/dL - Method: CBC Analyzer + DisciplineID: 2 + DepartmentID: 2 + Unit1: mg/dL + Method: CBC Analyzer PARAM_no_ref: summary: Parameter without reference or map value: @@ -234,11 +246,10 @@ VisibleScr: 1 VisibleRpt: 0 CountStat: 0 - details: - DisciplineID: 10 - DepartmentID: 0 - Unit1: cm - Method: Manual entry + DisciplineID: 10 + DepartmentID: 0 + Unit1: cm + Method: Manual entry TEST_range_single: summary: Technical test with numeric range reference (single) value: @@ -251,13 +262,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: RANGE - Unit1: mg/dL - Method: Hexokinase refnum: - NumRefType: NMRC RangeType: REF @@ -269,6 +273,12 @@ AgeStart: 18 AgeEnd: 99 Flag: N + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: RANGE + Unit1: mg/dL + Method: Hexokinase TEST_range_multiple_map: summary: Numeric reference with multiple ranges and test map value: @@ -281,13 +291,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: RANGE - Unit1: mg/dL - Method: Hexokinase refnum: - NumRefType: NMRC RangeType: REF @@ -304,7 +307,7 @@ Sex: '1' LowSign: '>' Low: 75 - HighSign: '<' + HighSign: < High: 105 AgeStart: 18 AgeEnd: 99 @@ -335,6 +338,12 @@ ConDefID: 3 ClientTestCode: HB_C ClientTestName: Hemoglobin Client + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: RANGE + Unit1: mg/dL + Method: Hexokinase TEST_threshold: summary: Technical test with threshold reference value: @@ -347,13 +356,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: THOLD - Unit1: mmol/L - Method: Auto Analyzer refnum: - NumRefType: THOLD RangeType: PANIC @@ -363,6 +365,12 @@ AgeStart: 0 AgeEnd: 125 Flag: H + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: THOLD + Unit1: mmol/L + Method: Auto Analyzer TEST_threshold_map: summary: Threshold reference plus test map value: @@ -375,13 +383,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 2 - DepartmentID: 2 - ResultType: NMRIC - RefType: THOLD - Unit1: mmol/L - Method: Auto Analyzer refnum: - NumRefType: THOLD RangeType: PANIC @@ -394,7 +395,7 @@ - NumRefType: THOLD RangeType: PANIC Sex: '1' - LowSign: '<' + LowSign: < Low: 121 AgeStart: 0 AgeEnd: 125 @@ -415,6 +416,12 @@ ConDefID: 1 ClientTestCode: GLU_C ClientTestName: Glucose Client + DisciplineID: 2 + DepartmentID: 2 + ResultType: NMRIC + RefType: THOLD + Unit1: mmol/L + Method: Auto Analyzer TEST_text: summary: Technical test with text reference value: @@ -427,20 +434,19 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 1 - DepartmentID: 1 - ResultType: TEXT - RefType: TEXT - Method: Morphology reftxt: - SpcType: GEN TxtRefType: TEXT Sex: '2' AgeStart: 18 AgeEnd: 99 - RefTxt: 'NORM=Normal;HIGH=High' + RefTxt: NORM=Normal;HIGH=High Flag: N + DisciplineID: 1 + DepartmentID: 1 + ResultType: TEXT + RefType: TEXT + Method: Morphology TEST_text_map: summary: Text reference plus test map value: @@ -453,25 +459,20 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 1 - DepartmentID: 1 - ResultType: TEXT - RefType: TEXT reftxt: - SpcType: GEN TxtRefType: TEXT Sex: '2' AgeStart: 18 AgeEnd: 99 - RefTxt: 'NORM=Normal' + RefTxt: NORM=Normal Flag: N - SpcType: GEN TxtRefType: TEXT Sex: '1' AgeStart: 18 AgeEnd: 99 - RefTxt: 'ABN=Abnormal' + RefTxt: ABN=Abnormal Flag: N testmap: - HostType: SITE @@ -484,6 +485,10 @@ ConDefID: 4 ClientTestCode: STAGE_C ClientTestName: Disease Stage Client + DisciplineID: 1 + DepartmentID: 1 + ResultType: TEXT + RefType: TEXT TEST_valueset: summary: Technical test using a value set result value: @@ -496,20 +501,19 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 4 - DepartmentID: 4 - ResultType: VSET - RefType: VSET - Method: Visual reftxt: - SpcType: GEN TxtRefType: VSET Sex: '2' AgeStart: 0 AgeEnd: 120 - RefTxt: 'NORM=Normal;MACRO=Macro' + RefTxt: NORM=Normal;MACRO=Macro Flag: N + DisciplineID: 4 + DepartmentID: 4 + ResultType: VSET + RefType: VSET + Method: Visual TEST_valueset_map: summary: Value set reference with test map value: @@ -522,18 +526,13 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 4 - DepartmentID: 4 - ResultType: VSET - RefType: VSET reftxt: - SpcType: GEN TxtRefType: VSET Sex: '2' AgeStart: 0 AgeEnd: 120 - RefTxt: 'NORM=Normal;ABN=Abnormal' + RefTxt: NORM=Normal;ABN=Abnormal Flag: N testmap: - HostType: SITE @@ -546,6 +545,10 @@ ConDefID: 12 ClientTestCode: UCOLOR_C ClientTestName: Urine Color Client + DisciplineID: 4 + DepartmentID: 4 + ResultType: VSET + RefType: VSET TEST_valueset_map_no_reftxt: summary: Value set result with mapping but without explicit text reference entries value: @@ -558,11 +561,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - DisciplineID: 4 - DepartmentID: 4 - ResultType: VSET - RefType: VSET testmap: - HostType: SITE HostID: '1' @@ -574,6 +572,10 @@ ConDefID: 12 ClientTestCode: UGLUC_C ClientTestName: Urine Glucose Client + DisciplineID: 4 + DepartmentID: 4 + ResultType: VSET + RefType: VSET CALC_basic: summary: Calculated test with members (no references) value: @@ -586,10 +588,11 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 0 - details: - DisciplineID: 2 - DepartmentID: 2 + DisciplineID: 2 + DepartmentID: 2 + testdefcal: FormulaCode: CKD_EPI(CREA,AGE,GENDER) + testdefgrp: members: - TestSiteID: 21 - TestSiteID: 22 @@ -605,13 +608,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 0 - details: - DisciplineID: 2 - DepartmentID: 2 - FormulaCode: CKD_EPI(CREA,AGE,GENDER) - members: - - TestSiteID: 21 - - TestSiteID: 22 refnum: - NumRefType: NMRC RangeType: REF @@ -634,6 +630,14 @@ ConDefID: 1 ClientTestCode: EGFR_C ClientTestName: eGFR Client + DisciplineID: 2 + DepartmentID: 2 + testdefcal: + FormulaCode: CKD_EPI(CREA,AGE,GENDER) + testdefgrp: + members: + - TestSiteID: 21 + - TestSiteID: 22 GROUP_with_members: summary: Group/profile test with members and mapping value: @@ -646,10 +650,6 @@ VisibleScr: 1 VisibleRpt: 1 CountStat: 1 - details: - members: - - TestSiteID: 169 - - TestSiteID: 170 testmap: - HostType: SITE HostID: '1' @@ -661,6 +661,11 @@ ConDefID: 1 ClientTestCode: LIPID_C ClientTestName: Lipid Client + testdefgrp: + members: + - TestSiteID: 169 + - TestSiteID: 170 + responses: '201': description: Test definition created @@ -761,56 +766,30 @@ type: integer VisibleRpt: type: integer - CountStat: - type: integer - details: - type: object - description: | - 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: - 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: - type: string - description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}") - Unit1: - type: string - Factor: - type: number - Unit2: - type: string - Decimal: - type: integer - default: 2 - Method: - type: string - ExpectedTAT: - type: integer - members: - type: array - description: | - 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: - type: object - properties: - TestSiteID: - type: integer - description: The actual TestSiteID of the member test (required) - required: - - TestSiteID + CountStat: + type: integer + testdefcal: + type: object + description: Calculated test metadata persisted in the `testdefcal` table. + properties: + FormulaCode: + type: string + description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}") + testdefgrp: + type: object + description: Group member payload stored in the `testdefgrp` table. + properties: + members: + type: array + description: Array of member TestSiteIDs for CALC/GROUP definitions. + items: + type: object + properties: + TestSiteID: + type: integer + description: Foreign key referencing the member test's TestSiteID. + required: + - TestSiteID refnum: type: array items: