erp-plugin-sdk/src/backend/services/core.service.ts

410 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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();
}
// ... (решта методів register, emit, request залишаються без змін)
async register(): Promise<void> {
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;
// Dynamically calculate the backendUrl
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}`);
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' },
body: JSON.stringify(manifest),
});
if (response.ok) {
this.logger.log(`Plugin registered successfully!`);
registered = true;
this.startHeartbeatLoop(manifest.id, backendUrl);
} else {
await new Promise(resolve => setTimeout(resolve, 5000));
}
} catch (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); // 30 seconds
}
/**
* Викликати API іншого плагіна через core RPC-проксі.
* Запит проксується ядром на backendUrl цільового плагіна методом POST.
*/
async callPluginApi<T = any>(pluginId: string, path: string, body?: any): Promise<T> {
return this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, {
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
/**
* Отримати список усіх зареєстрованих плагінів з їхніми events, objectTypes, backendUrl тощо.
* Корисно для discovery — дізнатись, які плагіни доступні та що вони вміють.
*/
async discoverPlugins(): Promise<any[]> {
const response = await this.fetchCore('/api/plugins/discovery');
return response.data || response || [];
}
/**
* Знайти плагіни, які оголосили певну подію у своєму manifest.json (поле events).
*/
async findPluginsByEvent(eventName: string): Promise<any[]> {
const plugins = await this.discoverPlugins();
return plugins.filter((p) =>
p.events?.some((e: { name: string }) => e.name === eventName),
);
}
/**
* Надіслати подію конкретному плагіну через RabbitMQ.
* Публікує в system_events з routingKey: plugin.<pluginId>.<pattern>.
* Цільовий плагін отримує подію через @EventPattern('plugin.<pluginId>.<pattern>')
* або більш загальний @EventPattern('plugin.*').
*
* Інші плагіни теж отримують подію (через # binding), але за конвенцією
* мають ігнорувати події з префіксом plugin.<чужойId>.
*
* IMPORTANT: Uses raw amqplib to publish to system_events exchange (not ClientProxy),
* because NestJS 10 ClientRMQ ignores the exchange option and sends directly to a queue.
*/
async emitToPlugin(pluginId: string, pattern: string, payload: any): Promise<void> {
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}`);
}
}
/**
* RPC-виклик до іншого плагіна з очікуванням відповіді.
* Використовує NestJS RabbitMQ RPC (send / @MessagePattern).
* Публікує в system_events exchange (raw amqplib, not ClientProxy).
* Цільовий плагін має обробити запит через @MessagePattern('plugin.<pluginId>.<pattern>').
*
* @param pluginId — ID цільового плагіна
* @param pattern — назва команди
* @param payload — тіло запиту
* @param timeoutMs — таймаут очікування (за замовчуванням 5000 мс)
* @returns відповідь від цільового плагіна або null по таймауту
*/
async requestPlugin<T = any>(
pluginId: string,
pattern: string,
payload: any,
timeoutMs: number = 5000,
): Promise<T | null> {
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<T | null>((resolve) => {
const timer = setTimeout(() => {
this.logger.warn(`[requestPlugin] Timeout for ${routingKey}`);
resolve(null);
}, timeoutMs);
ch.consume(replyQueue.queue, (rmqMsg) => {
if (rmqMsg && rmqMsg.properties.correlationId === correlationId) {
clearTimeout(timer);
try {
const parsed = JSON.parse(rmqMsg.content.toString());
resolve(parsed.response ?? parsed);
} catch {
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<T>(pattern: string, payload: T): Promise<void> {
const event: SystemEvent<T> = {
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<void> {
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<T = string>(key: string): Promise<T | null> {
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<void> {
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<void> {
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<void> {
await this.emit('system.notification.send', {
userId: payload.userId,
title: payload.title,
message: payload.message,
icon: payload.icon,
image: payload.image,
details: payload.details,
});
}
/**
* Отримати назву БД, наданої ядром (має сенс після виклику setupDatabase)
*/
getDbName(): string {
return process.env.PLUGIN_DB_NAME || process.env.DB_DATABASE || 'ultimate_crm';
}
/**
* Зареєструвати БД плагіна в ядрі (створює БД p_<id>, якщо ще не існує).
* Викликати ДО створення NestJS app, щоб TypeORM міг підхопити PLUGIN_DB_NAME.
*/
static async setupDatabase(options: { pluginId: string }): Promise<string> {
const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001';
const response = await fetch(`${coreUrl}/api/db/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pluginId: options.pluginId }),
});
if (!response.ok) {
throw new Error(`Database setup failed: ${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.PLUGIN_DB_HOST = data.host;
if (data.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;
}
}