import { Injectable, Inject, Logger } from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; import { firstValueFrom, timeout as rxTimeout } from 'rxjs'; import { SystemEvent } from '../interfaces/communication'; import { v4 as uuidv4 } from 'uuid'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as amqp from 'amqplib'; import { requestContext } from '../interceptors/request-context.interceptor'; @Injectable() export class CoreService { private readonly logger = new Logger(CoreService.name); private readonly serviceName = process.env.SERVICE_NAME || 'plugin'; private heartbeatInterval: any = null; constructor( @Inject('RABBITMQ_CLIENT') private readonly client: ClientProxy, ) {} async fetchCore(endpoint: string, options: RequestInit = {}) { const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const store = requestContext.getStore(); const authHeader = store?.get('authorization'); const headers = { 'Content-Type': 'application/json', ...(authHeader ? { 'Authorization': authHeader } : {}), ...((options.headers as any) || {}), }; this.logger.log(`[CoreRequest] Sending request to ${endpoint} (Auth: ${!!authHeader})`); const response = await fetch(`${coreUrl}${endpoint}`, { ...options, headers, }); if (!response.ok) { this.logger.error(`[CoreRequest] Request to ${endpoint} failed with status ${response.status}`); } return response.json(); } async register(): Promise { const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const possiblePaths = [ path.resolve(process.cwd(), '..', 'manifest.json'), path.resolve(process.cwd(), 'manifest.json'), ]; let manifestPath = ''; for (const p of possiblePaths) { if (fs.existsSync(p)) { manifestPath = p; break; } } if (!manifestPath) return; const port = process.env.PORT || '3002'; const serviceName = process.env.SERVICE_NAME || 'plugin'; const isDocker = coreUrl.includes('core-backend') || (!coreUrl.includes('localhost') && !coreUrl.includes('127.0.0.1')); const backendUrl = process.env.PLUGIN_BACKEND_URL || (isDocker ? `http://${serviceName}:${port}` : `http://localhost:${port}`); const registrationKey = process.env.REGISTRATION_KEY || ''; let registered = false; while (!registered) { try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); if (process.env.PUBLIC_URL) { let p = '/remoteEntry.js'; if (manifest.remoteEntry) { try { if (manifest.remoteEntry.startsWith('http://') || manifest.remoteEntry.startsWith('https://')) { p = new URL(manifest.remoteEntry).pathname; } else { p = manifest.remoteEntry; } } catch (e) { p = manifest.remoteEntry; } } const publicUrl = process.env.PUBLIC_URL.endsWith('/') ? process.env.PUBLIC_URL.slice(0, -1) : process.env.PUBLIC_URL; const pathname = p.startsWith('/') ? p : '/' + p; manifest.remoteEntry = `${publicUrl}${pathname}`; } manifest.backendUrl = backendUrl; const response = await fetch(`${coreUrl}/api/plugins/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(registrationKey ? { 'x-registration-key': registrationKey } : {}), }, body: JSON.stringify(manifest), }); if (response.ok) { const resBody = await response.json(); const data = resBody && resBody.data !== undefined ? resBody.data : resBody; this.logger.log(`Plugin registered successfully!`); if (data && data.dbCredentials) { const creds = data.dbCredentials; process.env.PLUGIN_DB_NAME = creds.dbName; if (creds.host && !process.env.DB_HOST) process.env.PLUGIN_DB_HOST = creds.host; if (creds.port && !process.env.DB_PORT) process.env.PLUGIN_DB_PORT = String(creds.port); process.env.PLUGIN_DB_USER = creds.username; process.env.PLUGIN_DB_PASSWORD = creds.password; this.logger.log(`Database credentials received for plugin ${manifest.id}`); } registered = true; this.startHeartbeatLoop(manifest.id, backendUrl); } else { const errText = await response.text(); this.logger.error(`Registration failed (${response.status}): ${errText}`); await new Promise(resolve => setTimeout(resolve, 5000)); } } catch (error) { this.logger.error(`Registration error: ${error}`); await new Promise(resolve => setTimeout(resolve, 5000)); } } } private startHeartbeatLoop(pluginId: string, backendUrl: string) { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } this.heartbeatInterval = setInterval(async () => { try { const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; this.logger.log(`[Heartbeat] Sending heartbeat for ${pluginId}`); const response = await fetch(`${coreUrl}/api/plugins/heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: pluginId, backendUrl }), }); if (response.ok) { const resBody = await response.json(); const data = resBody && resBody.data !== undefined ? resBody.data : resBody; if (data && data.registered === false) { this.logger.warn(`[Heartbeat] Core does not recognize plugin ${pluginId}. Re-registering.`); this.register(); } else { this.logger.log(`[Heartbeat] Heartbeat acknowledged by core.`); } } else { this.logger.error(`[Heartbeat] Failed to send heartbeat. Status: ${response.status}`); } } catch (error: any) { this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error); } }, 30 * 1000); } async callPluginApi(pluginId: string, path: string, body?: any): Promise { const res = await this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res && typeof res === 'object' && res.success === true) { if (res.data && typeof res.data === 'object' && res.data.success === true) { return res.data.data as T; } return res.data as T; } return res as T; } async callAsService(pluginId: string, path: string, body?: any): Promise { const registrationKey = process.env.REGISTRATION_KEY || ''; if (!registrationKey) { throw new Error('REGISTRATION_KEY not configured — cannot make service-to-service call'); } const payload = JSON.stringify({ pluginId, path: path.replace(/^\//, ''), timestamp: Date.now(), body: body !== undefined ? body : null, }); const signature = crypto.createHmac('sha256', registrationKey).update(payload).digest('hex'); const encoded = Buffer.from(payload).toString('base64'); const serviceAuth = `v1.${encoded}.${signature}`; const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const response = await fetch(`${coreUrl}/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Service-Auth': serviceAuth }, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!response.ok) { const text = await response.text().catch(() => response.statusText); throw new Error(`Service call to ${pluginId}/${path} failed: ${response.status} ${text}`); } const text = await response.text(); try { return JSON.parse(text); } catch { return text as any; } } async discoverPlugins(): Promise { try { const response = await this.fetchCore('/api/plugins/discovery'); if (Array.isArray(response)) return response; if (response && Array.isArray(response.data)) return response.data; return []; } catch { return []; } } async findPluginsByEvent(eventName: string): Promise { const plugins = await this.discoverPlugins(); return plugins.filter((p) => p.events?.some((e: { name: string }) => e.name === eventName), ); } async emitToPlugin(pluginId: string, pattern: string, payload: any): Promise { const routingKey = `plugin.${pluginId}.${pattern}`; const event: SystemEvent = { eventId: uuidv4(), pattern: routingKey, payload, source: this.serviceName, timestamp: Date.now(), }; try { const conn = await amqp.connect(this.getRMQUrl()); const ch = await conn.createChannel(); ch.publish('system_events', routingKey, Buffer.from(JSON.stringify(event)), { persistent: true, contentType: 'application/json', }); await ch.close(); await conn.close(); this.logger.log(`[emitToPlugin] Published to ${routingKey}`); } catch (err: any) { this.logger.error(`[emitToPlugin] Failed for ${routingKey}: ${err.message}`); } } async requestPlugin( pluginId: string, pattern: string, payload: any, timeoutMs: number = 5000, ): Promise { const routingKey = `plugin.${pluginId}.${pattern}`; const correlationId = uuidv4(); try { const conn = await amqp.connect(this.getRMQUrl()); const ch = await conn.createChannel(); const replyQueue = await ch.assertQueue('', { exclusive: true, autoDelete: true }); const msg = { id: correlationId, pattern: routingKey, data: payload, }; ch.publish('system_events', routingKey, Buffer.from(JSON.stringify(msg)), { correlationId, replyTo: replyQueue.queue, persistent: true, contentType: 'application/json', }); const result = await new Promise((resolve) => { const timer = setTimeout(() => { this.logger.warn(`[requestPlugin] Timeout for ${routingKey}`); if (errorFallback) clearTimeout(errorFallback); resolve(null); }, timeoutMs); let errorFallback: ReturnType | null = null; ch.consume(replyQueue.queue, (rmqMsg) => { if (rmqMsg && rmqMsg.properties.correlationId === correlationId) { try { const parsed = JSON.parse(rmqMsg.content.toString()); const response = parsed.response ?? parsed; // Skip "no matching handler" errors from other services // and wait for the correct response (up to timeoutMs - 1s) if (response && response.err) { if (!errorFallback) { errorFallback = setTimeout(() => { clearTimeout(timer); resolve(response); }, Math.min(timeoutMs - 1000, 15000)); } return; } if (errorFallback) clearTimeout(errorFallback); clearTimeout(timer); resolve(response); } catch { clearTimeout(timer); resolve(null); } } }, { noAck: true }); }); await ch.close(); await conn.close(); return result; } catch (err: any) { this.logger.error(`[requestPlugin] Failed for ${routingKey}: ${err.message}`); return null; } } private getRMQUrl(): string { return process.env.RABBITMQ_URL || 'amqp://localhost:5672'; } async emit(pattern: string, payload: T): Promise { const event: SystemEvent = { eventId: uuidv4(), pattern, payload, source: this.serviceName, timestamp: Date.now(), }; this.client.emit(pattern, event).subscribe({ error: (err) => this.logger.error(`Failed to emit event ${pattern}:`, err), }); } async auditLog(action: string, resource: string, details?: any): Promise { const store = requestContext.getStore(); const authHeader = store?.get('authorization'); const ipAddress = store?.get('ip'); const userAgent = store?.get('userAgent'); let userId: number | undefined; if (authHeader && authHeader.startsWith('Bearer ')) { try { const token = authHeader.split(' ')[1]; const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = Buffer.from(base64, 'base64').toString(); const payload = JSON.parse(jsonPayload); userId = payload.sub || payload.userId || payload.id; } catch (e) { this.logger.error('Failed to decode JWT for audit log', e); } } await this.emit('system.audit.log', { userId, action, resource, details, ipAddress, userAgent, }); } async getGlobalData(key: string): Promise { const response = await this.fetchCore(`/api/store/${key}`, { headers: { 'x-plugin-id': this.serviceName, }, }); if (response.success) { if (response.data === null || response.data === undefined) { return null; } try { return JSON.parse(response.data) as T; } catch (e) { return response.data as unknown as T; } } return null; } async setGlobalData(key: string, value: any): Promise { const stringValue = typeof value === 'string' ? value : JSON.stringify(value); await this.fetchCore(`/api/store/${key}`, { method: 'POST', body: JSON.stringify({ value: stringValue }), headers: { 'x-plugin-id': this.serviceName, }, }); } async deleteGlobalData(key: string): Promise { await this.fetchCore(`/api/store/${key}`, { method: 'DELETE', headers: { 'x-plugin-id': this.serviceName, }, }); } async sendNotification(payload: { userId: number | null; title: string; message: string; icon?: string; image?: string; details?: any }): Promise { await this.emit('system.notification.send', { userId: payload.userId, title: payload.title, message: payload.message, icon: payload.icon, image: payload.image, details: payload.details, }); } getDbName(): string { return process.env.PLUGIN_DB_NAME || process.env.DB_DATABASE || 'ultimate_crm'; } static async setupDatabase(options: { pluginId: string }): Promise { const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const registrationKey = process.env.REGISTRATION_KEY || ''; const headers: Record = { 'Content-Type': 'application/json' }; if (registrationKey) { headers['x-registration-key'] = registrationKey; } const response = await fetch(`${coreUrl}/api/db/setup`, { method: 'POST', headers, body: JSON.stringify({ pluginId: options.pluginId }), }); if (!response.ok) { throw new Error(`Database setup failed: ${response.status} ${response.statusText}`); } const result = await response.json(); const data = result.data || result; const dbName = data.dbName; if (!dbName) throw new Error('Database name not returned from core'); process.env.PLUGIN_DB_NAME = dbName; if (data.host && !process.env.DB_HOST) process.env.PLUGIN_DB_HOST = data.host; if (data.port && !process.env.DB_PORT) process.env.PLUGIN_DB_PORT = String(data.port); if (data.username) process.env.PLUGIN_DB_USER = data.username; if (data.password) process.env.PLUGIN_DB_PASSWORD = data.password; return dbName; } }