import { createClient, ClickHouseClient } from '@clickhouse/client'; /** * ClickHouse client singleton for audit logging */ export class ClickHouseService { private static instance: ClickHouseService; private client: ClickHouseClient | null = null; private config = { host: process.env.CLICKHOUSE_HOST || '', username: process.env.CLICKHOUSE_USER || 'default', password: process.env.CLICKHOUSE_PASSWORD || '', database: process.env.CLICKHOUSE_DATABASE || 'audit_logs', request_timeout: 60000, max_open_connections: 10, compression: { request: true, response: true } }; private constructor() {} static getInstance(): ClickHouseService { if (!ClickHouseService.instance) { ClickHouseService.instance = new ClickHouseService(); } return ClickHouseService.instance; } /** * Initialize the ClickHouse client */ async initialize(): Promise { if (!this.client) { this.client = createClient(this.config); await this.ensureDatabase(); await this.ensureTable(); } } /** * Get the ClickHouse client instance */ getClient(): ClickHouseClient { if (!this.client) { throw new Error('ClickHouse client not initialized. Call initialize() first.'); } return this.client; } /** * Ensure the audit database exists */ private async ensureDatabase(): Promise { try { await this.client!.query({ query: `CREATE DATABASE IF NOT EXISTS ${this.config.database}`, format: 'JSONEachRow' }); } catch (error) { console.error('Failed to create database:', error); throw error; } } /** * Ensure the audit events table exists */ private async ensureTable(): Promise { const createTableQuery = ` CREATE TABLE IF NOT EXISTS ${this.config.database}.audit_events ( id String, timestamp DateTime64(3), actor_id String, actor_email String, actor_name String, actor_role String, action String, resource_type String, resource_id String, resource_name String, severity LowCardinality(String), success Boolean, error_message Nullable(String), -- PHI reference fields (stored as encrypted references) patient_name Nullable(String), patient_email Nullable(String), patient_phone Nullable(String), patient_dob Nullable(String), patient_ssn Nullable(String), patient_mrn Nullable(String), diagnosis Nullable(String), treatment_details Nullable(String), medications Nullable(String), -- Non-PHI metadata ip_address Nullable(String), user_agent Nullable(String), department Nullable(String), location Nullable(String), session_id Nullable(String), request_id Nullable(String), -- Custom metadata as JSON metadata String, -- Indexing and partitioning date Date DEFAULT toDate(timestamp) ) ENGINE = MergeTree() PARTITION BY toYYYYMM(date) ORDER BY (timestamp, actor_id, action) TTL date + INTERVAL 7 YEAR SETTINGS index_granularity = 8192 `; try { await this.client!.query({ query: createTableQuery, format: 'JSONEachRow' }); // Create materialized views for common queries await this.createMaterializedViews(); } catch (error) { console.error('Failed to create table:', error); throw error; } } /** * Create materialized views for performance optimization */ private async createMaterializedViews(): Promise { // Daily activity summary const dailyActivityView = ` CREATE MATERIALIZED VIEW IF NOT EXISTS ${this.config.database}.daily_activity_mv ENGINE = SummingMergeTree() ORDER BY (date, actor_id, action) AS SELECT date, actor_id, action, count() as event_count, countIf(success = 1) as success_count, countIf(success = 0) as failure_count FROM ${this.config.database}.audit_events GROUP BY date, actor_id, action `; // Resource access patterns const resourceAccessView = ` CREATE MATERIALIZED VIEW IF NOT EXISTS ${this.config.database}.resource_access_mv ENGINE = AggregatingMergeTree() ORDER BY (resource_type, resource_id, date) AS SELECT resource_type, resource_id, date, uniqExact(actor_id) as unique_actors, count() as access_count, groupArray(action) as actions FROM ${this.config.database}.audit_events GROUP BY resource_type, resource_id, date `; try { await this.client!.query({ query: dailyActivityView, format: 'JSONEachRow' }); await this.client!.query({ query: resourceAccessView, format: 'JSONEachRow' }); } catch (error) { // Views might already exist, log but don't throw console.warn('Failed to create materialized views:', error); } } /** * Insert audit events into ClickHouse */ async insertEvents(events: any[]): Promise { if (!this.client) { await this.initialize(); } try { await this.client!.insert({ table: `${this.config.database}.audit_events`, values: events, format: 'JSONEachRow' }); } catch (error) { console.error('Failed to insert events:', error); throw error; } } /** * Query audit events */ async queryEvents(query: string, params?: Record): Promise { if (!this.client) { await this.initialize(); } try { const result = await this.client!.query({ query, format: 'JSONEachRow', query_params: params }); return await result.json(); } catch (error) { console.error('Failed to query events:', error); throw error; } } /** * Close the ClickHouse connection */ async close(): Promise { if (this.client) { await this.client.close(); this.client = null; } } } // Export singleton instance export const clickhouseService = ClickHouseService.getInstance();