diff --git a/.cocoindex_code/cocoindex.db/mdb/data.mdb b/.cocoindex_code/cocoindex.db/mdb/data.mdb index fe49e35..d984184 100644 Binary files a/.cocoindex_code/cocoindex.db/mdb/data.mdb and b/.cocoindex_code/cocoindex.db/mdb/data.mdb differ diff --git a/.cocoindex_code/target_sqlite.db b/.cocoindex_code/target_sqlite.db index 88fa5f4..55fe3ef 100644 Binary files a/.cocoindex_code/target_sqlite.db and b/.cocoindex_code/target_sqlite.db differ diff --git a/app/Database/Migrations/2026-03-25-000050_CreateAuditLogs.php b/app/Database/Migrations/2026-03-25-000050_CreateAuditLogs.php new file mode 100644 index 0000000..6a3ea60 --- /dev/null +++ b/app/Database/Migrations/2026-03-25-000050_CreateAuditLogs.php @@ -0,0 +1,137 @@ + '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: