mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: event bus with Redpanda + AI insight consumer
- EventBusService: Kafka/Redpanda pub/sub with kafkajs, graceful fallback when broker unavailable - Topics: call.completed, call.missed, agent.state - AiInsightConsumer: on call.completed, fetches lead activity → OpenAI generates summary + suggested action → updates Lead entity on platform - Disposition endpoint emits call.completed event after Ozonetel dispose - EventsModule registered as @Global for cross-module injection - Redpanda container added to VPS docker-compose - Docker image rebuilt for linux/amd64 with kafkajs dependency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
114
src/events/event-bus.service.ts
Normal file
114
src/events/event-bus.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
|
||||
import type { EventPayload } from './event-types';
|
||||
|
||||
type EventHandler = (payload: any) => Promise<void>;
|
||||
|
||||
@Injectable()
|
||||
export class EventBusService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventBusService.name);
|
||||
private kafka: Kafka;
|
||||
private producer: Producer;
|
||||
private consumer: Consumer;
|
||||
private handlers = new Map<string, EventHandler[]>();
|
||||
private connected = false;
|
||||
|
||||
constructor() {
|
||||
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
|
||||
this.kafka = new Kafka({
|
||||
clientId: 'helix-engage-sidecar',
|
||||
brokers,
|
||||
retry: { retries: 5, initialRetryTime: 1000 },
|
||||
logLevel: 1, // ERROR only
|
||||
});
|
||||
this.producer = this.kafka.producer();
|
||||
this.consumer = this.kafka.consumer({ groupId: 'helix-engage-workers' });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.producer.connect();
|
||||
await this.consumer.connect();
|
||||
this.connected = true;
|
||||
this.logger.log('Event bus connected (Kafka/Redpanda)');
|
||||
|
||||
// Subscribe to all topics we have handlers for
|
||||
// Handlers are registered by consumer modules during their onModuleInit
|
||||
// We start consuming after a short delay to let all handlers register
|
||||
setTimeout(() => this.startConsuming(), 2000);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Event bus not available (${err.message}) — running without events`);
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.connected) {
|
||||
await this.consumer.disconnect().catch(() => {});
|
||||
await this.producer.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async emit(topic: string, payload: EventPayload): Promise<void> {
|
||||
if (!this.connected) {
|
||||
this.logger.debug(`[EVENT] Skipped (not connected): ${topic}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.producer.send({
|
||||
topic,
|
||||
messages: [{ value: JSON.stringify(payload), timestamp: Date.now().toString() }],
|
||||
});
|
||||
this.logger.log(`[EVENT] Emitted: ${topic}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[EVENT] Failed to emit ${topic}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
on(topic: string, handler: EventHandler): void {
|
||||
const existing = this.handlers.get(topic) ?? [];
|
||||
existing.push(handler);
|
||||
this.handlers.set(topic, existing);
|
||||
this.logger.log(`[EVENT] Handler registered for: ${topic}`);
|
||||
}
|
||||
|
||||
private async startConsuming(): Promise<void> {
|
||||
if (!this.connected) return;
|
||||
|
||||
const topics = Array.from(this.handlers.keys());
|
||||
if (topics.length === 0) {
|
||||
this.logger.log('[EVENT] No handlers registered — skipping consumer');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const topic of topics) {
|
||||
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||
}
|
||||
|
||||
await this.consumer.run({
|
||||
eachMessage: async (payload: EachMessagePayload) => {
|
||||
const { topic, message } = payload;
|
||||
const handlers = this.handlers.get(topic) ?? [];
|
||||
if (handlers.length === 0 || !message.value) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message.value.toString());
|
||||
for (const handler of handlers) {
|
||||
await handler(data).catch(err =>
|
||||
this.logger.error(`[EVENT] Handler error on ${topic}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[EVENT] Parse error on ${topic}: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[EVENT] Consuming: ${topics.join(', ')}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[EVENT] Consumer failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user