feat: add audit documentation and viewer
This commit is contained in:
parent
3e7ba218f2
commit
1def8c747e
352
docs/audit-logging.md
Normal file
352
docs/audit-logging.md
Normal file
@ -0,0 +1,352 @@
|
||||
# Audit Logging Strategy (Implementation Ready)
|
||||
|
||||
## 1) Purpose, Scope, and Non-Goals
|
||||
|
||||
This document defines the production audit logging contract for CLQMS.
|
||||
|
||||
### Purpose
|
||||
|
||||
- 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.
|
||||
|
||||
### Scope
|
||||
|
||||
This applies to four log tables:
|
||||
|
||||
- `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.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- 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.
|
||||
|
||||
## 2) Table Ownership
|
||||
|
||||
Use this mapping to choose the target table and minimum event shape.
|
||||
|
||||
| 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` |
|
||||
|
||||
## 3) Canonical Schema (All Four Tables)
|
||||
|
||||
All four tables MUST implement the same logical columns. Physical PK name may vary (`LogPatientID`, `LogOrderID`, etc.).
|
||||
|
||||
### 3.1 Column contract
|
||||
|
||||
| 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` |
|
||||
|
||||
### 3.2 Required/conditional rules
|
||||
|
||||
- `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).
|
||||
|
||||
## 4) DDL Template and Indexing
|
||||
|
||||
Use this template when creating a log table. Replace `${TABLE}` and `${PK}`.
|
||||
|
||||
```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`)
|
||||
);
|
||||
```
|
||||
|
||||
Optional JSON path index (DB engine specific):
|
||||
|
||||
- `Context.request_id`
|
||||
- `Context.batch_id`
|
||||
- `Context.job_name`
|
||||
|
||||
## 5) Context JSON Contract
|
||||
|
||||
`Context` MUST be valid JSON. Keep payload compact and predictable.
|
||||
|
||||
### 5.1 Required keys for all events
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "a4f5b6c7",
|
||||
"route": "PATCH /api/patient/123",
|
||||
"timestamp_utc": "2026-03-25T04:45:12.551Z",
|
||||
"entity_type": "patient",
|
||||
"entity_version": 7
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Additional keys by event class
|
||||
|
||||
- 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`.
|
||||
|
||||
### 5.3 Size and shape limits
|
||||
|
||||
- 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.
|
||||
|
||||
## 6) Activity and Event Catalog Governance
|
||||
|
||||
`EventID` values MUST come from the ValueSet library, not hardcoded inline strings.
|
||||
|
||||
- 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)`
|
||||
|
||||
### 6.1 Allowed `ActivityID`
|
||||
|
||||
`CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`, `LOCK`, `UNLOCK`, `RESET`
|
||||
|
||||
### 6.2 `EventID` naming pattern
|
||||
|
||||
- Format: `<DOMAIN>_<OBJECT>_<ACTION>`
|
||||
- Character set: uppercase A-Z, numbers, underscore.
|
||||
- Max length: 80.
|
||||
- Examples: `PATIENT_DEMOGRAPHICS_UPDATED`, `ORDER_CANCELLED`, `AUTH_LOGIN_FAILED`.
|
||||
|
||||
### 6.3 Catalog lifecycle
|
||||
|
||||
- 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.
|
||||
|
||||
## 7) Minimum Event Coverage (Must Implement)
|
||||
|
||||
### 7.1 `logpatient`
|
||||
|
||||
- `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`
|
||||
|
||||
### 7.2 `logorder`
|
||||
|
||||
- `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`
|
||||
|
||||
### 7.3 `logmaster`
|
||||
|
||||
- `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`
|
||||
|
||||
### 7.4 `logsystem`
|
||||
|
||||
- `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`
|
||||
|
||||
## 8) Capture Rules (Application Behavior)
|
||||
|
||||
### 8.1 Write timing
|
||||
|
||||
- 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.
|
||||
|
||||
### 8.2 Failure policy
|
||||
|
||||
- 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.
|
||||
21
src/lib/api/audit-logs.js
Normal file
21
src/lib/api/audit-logs.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { get } from './client.js';
|
||||
|
||||
/**
|
||||
* Fetch audit log rows with optional filters.
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {string} params.table - Target log table (logpatient, logorder, etc.)
|
||||
* @param {string|number} params.rec_id - Primary record identifier
|
||||
* @param {string} [params.event_id]
|
||||
* @param {string} [params.activity_id]
|
||||
* @param {string} [params.request_id]
|
||||
* @param {string} [params.from]
|
||||
* @param {string} [params.to]
|
||||
* @param {string} [params.search]
|
||||
* @param {number} [params.page=1]
|
||||
* @param {number} [params.perPage=20]
|
||||
* @returns {Promise<Object>} API response containing { data, pagination }
|
||||
*/
|
||||
export async function fetchAuditLogs(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/audit-logs?${query}` : '/api/audit-logs');
|
||||
}
|
||||
400
src/lib/components/AuditLogModal.svelte
Normal file
400
src/lib/components/AuditLogModal.svelte
Normal file
@ -0,0 +1,400 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { fetchAuditLogs } from '$lib/api/audit-logs.js';
|
||||
import { History, Search, RefreshCw, Clock } from 'lucide-svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
const ACTIVITY_OPTIONS = [
|
||||
'CREATE',
|
||||
'UPDATE',
|
||||
'DELETE',
|
||||
'READ',
|
||||
'MERGE',
|
||||
'SPLIT',
|
||||
'CANCEL',
|
||||
'REOPEN',
|
||||
'VERIFY',
|
||||
'AMEND',
|
||||
'RETRACT',
|
||||
'RELEASE',
|
||||
'IMPORT',
|
||||
'EXPORT',
|
||||
'LOGIN',
|
||||
'LOGOUT',
|
||||
'LOCK',
|
||||
'UNLOCK',
|
||||
'RESET'
|
||||
];
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
function getDefaultFromDate() {
|
||||
const date = new SvelteDate();
|
||||
date.setDate(date.getDate() - 7);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function getDefaultToDate() {
|
||||
return new SvelteDate().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function parseContext(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatContext(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new SvelteDate(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
table = '',
|
||||
recId = '',
|
||||
entityLabel = 'record'
|
||||
} = $props();
|
||||
|
||||
let filters = $state({
|
||||
eventId: '',
|
||||
activityId: '',
|
||||
search: '',
|
||||
from: getDefaultFromDate(),
|
||||
to: getDefaultToDate()
|
||||
});
|
||||
|
||||
let auditRows = $state([]);
|
||||
let pagination = $state({ page: 1, perPage: DEFAULT_PAGE_SIZE, total: 0 });
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let selectedRow = $state(null);
|
||||
|
||||
const columns = [
|
||||
{ key: 'LogDate', label: 'Event time', class: 'w-36' },
|
||||
{ key: 'EventID', label: 'Event ID', class: 'w-32 text-xs font-semibold' },
|
||||
{ key: 'ActivityID', label: 'Activity', class: 'w-24' },
|
||||
{ key: 'TblName', label: 'Table', class: 'w-20' },
|
||||
{ key: 'RecID', label: 'Rec ID', class: 'w-24' },
|
||||
{ key: 'UserID', label: 'User', class: 'w-24' },
|
||||
{ key: 'SiteID', label: 'Site', class: 'w-20' },
|
||||
{ key: '_requestId', label: 'Request', class: 'w-32' },
|
||||
{ key: 'Reason', label: 'Reason', class: 'flex-1' }
|
||||
];
|
||||
|
||||
async function loadLogs(page = 1) {
|
||||
if (!table || !recId || !open) {
|
||||
auditRows = [];
|
||||
pagination = { ...pagination, total: 0, page: 1 };
|
||||
selectedRow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedRow = null;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const params = {
|
||||
table,
|
||||
rec_id: recId,
|
||||
page,
|
||||
perPage: pagination.perPage
|
||||
};
|
||||
|
||||
if (filters.eventId.trim()) {
|
||||
params.event_id = filters.eventId.trim();
|
||||
}
|
||||
if (filters.activityId) {
|
||||
params.activity_id = filters.activityId;
|
||||
}
|
||||
if (filters.search.trim()) {
|
||||
params.search = filters.search.trim();
|
||||
}
|
||||
if (filters.from) {
|
||||
params.from = filters.from;
|
||||
}
|
||||
if (filters.to) {
|
||||
params.to = filters.to;
|
||||
}
|
||||
|
||||
const response = await fetchAuditLogs(params);
|
||||
const rows = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
auditRows = rows.map((row) => {
|
||||
const parsedContext = parseContext(row.Context) ?? {};
|
||||
return {
|
||||
...row,
|
||||
_parsedContext: parsedContext,
|
||||
_requestId: parsedContext.request_id || ''
|
||||
};
|
||||
});
|
||||
|
||||
pagination = {
|
||||
page: response.pagination?.page || page,
|
||||
perPage: response.pagination?.perPage || pagination.perPage,
|
||||
total: response.pagination?.total ?? rows.length
|
||||
};
|
||||
|
||||
selectedRow = auditRows[0] || null;
|
||||
} catch (err) {
|
||||
error = err.message || 'Unable to load audit history.';
|
||||
auditRows = [];
|
||||
pagination = { ...pagination, total: 0, page };
|
||||
selectedRow = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters = {
|
||||
eventId: '',
|
||||
activityId: '',
|
||||
search: '',
|
||||
from: getDefaultFromDate(),
|
||||
to: getDefaultToDate()
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
pagination = { ...pagination, page: 1 };
|
||||
loadLogs(1);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
resetFilters();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
if (newPage < 1 || newPage > getTotalPages()) {
|
||||
return;
|
||||
}
|
||||
pagination = { ...pagination, page: newPage };
|
||||
loadLogs(newPage);
|
||||
}
|
||||
function getTotalPages() {
|
||||
return Math.max(Math.ceil(pagination.total / pagination.perPage), 1);
|
||||
}
|
||||
|
||||
function getRangeStart() {
|
||||
if (pagination.total === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (pagination.page - 1) * pagination.perPage + 1;
|
||||
}
|
||||
|
||||
function getRangeEnd() {
|
||||
return Math.min(pagination.page * pagination.perPage, pagination.total);
|
||||
}
|
||||
|
||||
export async function reload({ resetFilters: reset = false, page = 1 } = {}) {
|
||||
if (reset) {
|
||||
resetFilters();
|
||||
pagination = { ...pagination, page };
|
||||
} else {
|
||||
pagination = { ...pagination, page };
|
||||
}
|
||||
await loadLogs(page);
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
selectedRow = null;
|
||||
auditRows = [];
|
||||
error = '';
|
||||
}
|
||||
|
||||
function selectRow(row) {
|
||||
selectedRow = row;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:open
|
||||
title={`Audit trail · ${entityLabel}`}
|
||||
size="xl"
|
||||
closable={true}
|
||||
on:close={handleModalClose}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-base-content/70">
|
||||
<History class="w-4 h-4 text-secondary" />
|
||||
<span>Table: {table || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-200 p-3 shadow-sm">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3">
|
||||
<label class="form-control">
|
||||
<span class="label-text text-xs">Event ID</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered"
|
||||
placeholder="Event ID"
|
||||
bind:value={filters.eventId}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text text-xs">Activity</span>
|
||||
<select class="select select-sm select-bordered" bind:value={filters.activityId}>
|
||||
<option value="">All activities</option>
|
||||
{#each ACTIVITY_OPTIONS as activity (activity)}
|
||||
<option value={activity}>{activity}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text text-xs">Time from</span>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-sm input-bordered"
|
||||
bind:value={filters.from}
|
||||
max={filters.to || undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text text-xs">Time to</span>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-sm input-bordered"
|
||||
bind:value={filters.to}
|
||||
min={filters.from || undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text text-xs">Keyword / Request ID</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered grow"
|
||||
placeholder="Search context"
|
||||
bind:value={filters.search}
|
||||
/>
|
||||
<button class="btn btn-sm btn-ghost" onclick={applyFilters} title="Apply filters">
|
||||
<Search class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-base-content/60 mt-2">
|
||||
<span>
|
||||
Showing {getRangeStart()}–{getRangeEnd()} of {pagination.total}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs btn-ghost" onclick={clearFilters}>
|
||||
Reset
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline" onclick={() => loadLogs(pagination.page)}>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="card bg-base-100 border border-base-200 shadow-sm overflow-hidden">
|
||||
<div class="card-body p-0">
|
||||
{#if error}
|
||||
<div class="p-4 text-sm text-error">{error}</div>
|
||||
{:else if loading}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if auditRows.length === 0}
|
||||
<div class="text-center p-8 text-sm text-base-content/50">
|
||||
<Clock class="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p>No audit records match the current filters.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={auditRows}
|
||||
loading={loading}
|
||||
emptyMessage="No audit entries"
|
||||
onRowClick={selectRow}
|
||||
>
|
||||
{#snippet cell({ column, value })}
|
||||
{#if column.key === 'LogDate'}
|
||||
<span class="text-xs font-mono">{formatDate(value)}</span>
|
||||
{:else if column.key === 'Reason'}
|
||||
<span class="text-xs text-base-content/70 truncate max-w-[200px] block">{value || '-'}</span>
|
||||
{:else if column.key === '_requestId'}
|
||||
<span class="text-xs font-mono">{value || '-'}</span>
|
||||
{:else}
|
||||
<span class="text-xs">{value || '-'}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-200 shadow-sm p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-xs uppercase tracking-widest text-secondary">Context detail</div>
|
||||
{#if selectedRow}
|
||||
<span class="text-xs text-base-content/60">{selectedRow.EventID || '—'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedRow}
|
||||
<div class="grid grid-cols-2 gap-3 text-xs text-base-content/70 mb-3">
|
||||
<div>
|
||||
<div class="text-[10px] text-base-content/60">Request ID</div>
|
||||
<div class="font-mono text-sm text-base-content">{selectedRow._requestId || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] text-base-content/60">Route</div>
|
||||
<div class="text-sm">{selectedRow._parsedContext.route || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] text-base-content/60">User / Site</div>
|
||||
<div class="text-sm">{selectedRow.UserID || '-'} / {selectedRow.SiteID || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] text-base-content/60">Session</div>
|
||||
<div class="text-sm">{selectedRow.SessionID || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-base-200/70 p-2 text-[10px] font-mono leading-tight max-h-64 overflow-auto">
|
||||
{formatContext(selectedRow._parsedContext ?? selectedRow.Context)}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/60 mt-2">
|
||||
{selectedRow.Reason || 'No reason provided'}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/60">Select an audit entry to inspect its full context and metadata.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
/**
|
||||
@ -25,6 +26,8 @@
|
||||
footer,
|
||||
} = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'modal-sm',
|
||||
md: '',
|
||||
@ -49,6 +52,7 @@
|
||||
function close() {
|
||||
if (closable) {
|
||||
open = false;
|
||||
dispatch('close');
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import TestFormModal from './test-modal/TestFormModal.svelte';
|
||||
import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.svelte';
|
||||
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users, ChevronLeft, ChevronRight, Hash, Type } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
// Pagination and search state
|
||||
let loading = $state(false);
|
||||
@ -228,7 +229,7 @@
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/master-data" class="btn btn-ghost btn-circle">
|
||||
<a href={resolve('/master-data')} class="btn btn-ghost btn-circle">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
|
||||
@ -325,7 +325,7 @@ import RulesTab from './tabs/RulesTab.svelte';
|
||||
await createTest(formData);
|
||||
toastSuccess('Test created successfully');
|
||||
} else {
|
||||
await updateTest(formData);
|
||||
await updateTest(formData.TestSiteID, formData);
|
||||
toastSuccess('Test updated successfully');
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { fetchPatients, fetchPatient, deletePatient } from '$lib/api/patients.js';
|
||||
import { fetchOrders, createOrder } from '$lib/api/orders.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
@ -11,6 +11,7 @@ import { printPatientWristband, printSpecimenLabel } from '$lib/utils/barcode.js
|
||||
import OrderFormModal from '../orders/OrderFormModal.svelte';
|
||||
import OrderDetailModal from '../orders/OrderDetailModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import AuditLogModal from '$lib/components/AuditLogModal.svelte';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-svelte';
|
||||
|
||||
// Search state (only PatientID and Name are supported by API)
|
||||
@ -59,6 +60,10 @@ import { printPatientWristband, printSpecimenLabel } from '$lib/utils/barcode.js
|
||||
loading: false
|
||||
});
|
||||
|
||||
let auditModalOpen = $state(false);
|
||||
let auditModalPatient = $state(null);
|
||||
let auditLogModalRef;
|
||||
|
||||
// Load patients on mount (empty on init)
|
||||
onMount(() => {
|
||||
// Don't auto-load - wait for search
|
||||
@ -224,6 +229,17 @@ import { printPatientWristband, printSpecimenLabel } from '$lib/utils/barcode.js
|
||||
viewOrderModal = { open: true, order, loading: false };
|
||||
}
|
||||
|
||||
async function handleViewPatientAudit(patient) {
|
||||
auditModalPatient = patient;
|
||||
auditModalOpen = true;
|
||||
await tick();
|
||||
auditLogModalRef?.reload({ resetFilters: true });
|
||||
}
|
||||
|
||||
function handleAuditModalClose() {
|
||||
auditModalPatient = null;
|
||||
}
|
||||
|
||||
function handlePrintBarcode(order) {
|
||||
printSpecimenLabel(order);
|
||||
}
|
||||
@ -282,6 +298,7 @@ import { printPatientWristband, printSpecimenLabel } from '$lib/utils/barcode.js
|
||||
onPageChange={handlePageChange}
|
||||
onSelectPatient={handleShowOrders}
|
||||
onEditPatient={openEditModal}
|
||||
onViewAudit={handleViewPatientAudit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -332,6 +349,15 @@ import { printPatientWristband, printSpecimenLabel } from '$lib/utils/barcode.js
|
||||
onClose={handleCloseOrderDetail}
|
||||
/>
|
||||
|
||||
<AuditLogModal
|
||||
bind:open={auditModalOpen}
|
||||
bind:this={auditLogModalRef}
|
||||
table="logpatient"
|
||||
recId={auditModalPatient?.InternalPID ?? ''}
|
||||
entityLabel={auditModalPatient?.PatientID || auditModalPatient?.InternalPID || 'patient'}
|
||||
on:close={handleAuditModalClose}
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, Users, Edit2 } from 'lucide-svelte';
|
||||
import { ChevronLeft, ChevronRight, Users, Edit2, History } from 'lucide-svelte';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { formatPatientName, formatSex, formatBirthdate } from '$lib/utils/patients.js';
|
||||
|
||||
@ -40,7 +40,8 @@
|
||||
perPage = 20,
|
||||
onPageChange,
|
||||
onSelectPatient,
|
||||
onEditPatient
|
||||
onEditPatient,
|
||||
onViewAudit
|
||||
} = $props();
|
||||
|
||||
const columns = [
|
||||
@ -95,6 +96,18 @@
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
{#if onViewAudit}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="View audit trail"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewAudit(row);
|
||||
}}
|
||||
>
|
||||
<History class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<span
|
||||
class="truncate block"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user