import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; export interface ChQueryResult { data: Record[]; rows: number; elapsed: number; error?: string; } const SAFE_QUERIES: Record) => string> = { money_raise_all: () => `SELECT category, subcategory, region, stage, title, content, format, tags, source, language FROM money_raise.knowledge_base ORDER BY category, subcategory LIMIT 500`, tables: () => `SELECT database, name, engine, total_rows, formatReadableSize(total_bytes) AS size FROM system.tables WHERE database NOT IN ('system','information_schema','INFORMATION_SCHEMA') ORDER BY database, name LIMIT 100`, legislation_search: ({ q }) => `SELECT * FROM default.legislation WHERE lower(content) LIKE lower('%${q.replace(/'/g, "\\'")}%') LIMIT 20`, count_rows: ({ table }) => `SELECT count() AS rows FROM ${table.replace(/[^a-zA-Z0-9_.]/g, '')}`, recent_rows: ({ table, limit = '10' }) => `SELECT * FROM ${table.replace(/[^a-zA-Z0-9_.]/g, '')} LIMIT ${Math.min(parseInt(limit) || 10, 100)}`, databases: () => `SELECT name, engine FROM system.databases ORDER BY name`, }; @Injectable() export class IntelligenceService { private readonly logger = new Logger(IntelligenceService.name); private readonly baseUrl: string; private readonly user: string; private readonly password: string; private readonly database: string; constructor(private readonly config: ConfigService) { this.baseUrl = config.get('CLICKHOUSE_URL', 'http://152.53.112.35:8123'); this.user = config.get('CLICKHOUSE_USER', 'default'); this.password = config.get('CLICKHOUSE_PASSWORD', ''); this.database = config.get('CLICKHOUSE_DATABASE', 'default'); } private authHeader(): string { return 'Basic ' + Buffer.from(`${this.user}:${this.password}`).toString('base64'); } async query(sql: string): Promise { const url = new URL(this.baseUrl); url.searchParams.set('database', this.database); url.searchParams.set('default_format', 'JSON'); const t0 = Date.now(); let res: Response; try { res = await fetch(url.toString(), { method: 'POST', headers: { 'Authorization': this.authHeader(), 'X-ClickHouse-Format': 'JSON', 'Content-Type': 'text/plain', }, body: sql, signal: AbortSignal.timeout(15_000), }); } catch (err: any) { this.logger.error(`ClickHouse unreachable: ${err.message}`); return { data: [], rows: 0, elapsed: Date.now() - t0, error: 'ClickHouse unreachable. Check CLICKHOUSE_URL.' }; } const elapsed = Date.now() - t0; if (!res.ok) { const body = await res.text(); return { data: [], rows: 0, elapsed, error: `ClickHouse ${res.status}: ${body.slice(0, 200)}` }; } const json = await res.json() as { data: Record[]; rows: number }; return { data: json.data ?? [], rows: json.rows ?? 0, elapsed }; } async safeQuery(queryKey: string, params: Record = {}): Promise { const builder = SAFE_QUERIES[queryKey]; if (!builder) return { data: [], rows: 0, elapsed: 0, error: `Unknown query: ${queryKey}` }; return this.query(builder(params)); } async status(): Promise<{ ok: boolean; version?: string; error?: string }> { const r = await this.query('SELECT version() AS version'); if (r.error) return { ok: false, error: r.error }; return { ok: true, version: String(r.data[0]?.version ?? '') }; } }