From 3136f0b5f479ab67f070374de95c0d43a0aaf481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=9E=D1=81=D0=B8?= =?UTF-8?q?=D0=BF=D0=BA=D0=BE=D0=B2?= Date: Mon, 1 Jun 2026 11:02:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20ReportCapabilityProvider=20factor?= =?UTF-8?q?y=20for=20report=20data=20sources=20=E2=80=94=20v1.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 +- package.json | 2 +- .../interfaces/report-capability.types.ts | 31 +++++++ src/backend/services/core.service.ts | 93 ++++++++----------- .../services/report-capability.service.ts | 49 ++++++++++ src/index.ts | 2 + 6 files changed, 122 insertions(+), 59 deletions(-) create mode 100644 src/backend/interfaces/report-capability.types.ts create mode 100644 src/backend/services/report-capability.service.ts diff --git a/package-lock.json b/package-lock.json index 636ba19..da87c2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@erp/plugin-sdk", - "version": "1.5.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@erp/plugin-sdk", - "version": "1.5.0", + "version": "1.9.0", "license": "ISC", "dependencies": { "uuid": "^9.0.1" diff --git a/package.json b/package.json index 788eb75..2cfd612 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@erp/plugin-sdk", - "version": "1.7.1", + "version": "1.9.0", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { diff --git a/src/backend/interfaces/report-capability.types.ts b/src/backend/interfaces/report-capability.types.ts new file mode 100644 index 0000000..82aa679 --- /dev/null +++ b/src/backend/interfaces/report-capability.types.ts @@ -0,0 +1,31 @@ +export interface CapabilityField { + name: string; + type: 'string' | 'number' | 'currency' | 'date' | 'percent'; + label: string; +} + +export interface CapabilityFilter { + name: string; + type: 'date' | 'dateRange' | 'select' | 'text' | 'number'; + label: string; + options?: { label: string; value: string }[]; +} + +export interface ReportEndpointDef { + endpoint: string; + label: string; + description?: string; + outputFields: CapabilityField[]; + filters: CapabilityFilter[]; +} + +export interface ReportCapabilitiesResponse { + endpoints: ReportEndpointDef[]; +} + +export interface ReportControllerOptions { + pluginId: string; + endpoints: ReportEndpointDef[]; + handlerToken: any; + methodPrefix?: string; +} diff --git a/src/backend/services/core.service.ts b/src/backend/services/core.service.ts index 167eeef..f8bfc3a 100644 --- a/src/backend/services/core.service.ts +++ b/src/backend/services/core.service.ts @@ -18,9 +18,6 @@ export class CoreService { @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(); @@ -46,15 +43,13 @@ export class CoreService { return response.json(); } - // ... (решта методів register, emit, request залишаються без змін) - 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)) { @@ -65,12 +60,13 @@ export class CoreService { 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}`); + const registrationKey = process.env.REGISTRATION_KEY || ''; + let registered = false; while (!registered) { try { @@ -96,18 +92,38 @@ export class CoreService { const response = await fetch(`${coreUrl}/api/plugins/register`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + 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)); } } @@ -126,7 +142,7 @@ export class CoreService { 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; @@ -142,13 +158,9 @@ export class CoreService { } catch (error: any) { this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error); } - }, 30 * 1000); // 30 seconds + }, 30 * 1000); } - /** - * Викликати API іншого плагіна через core RPC-проксі. - * Запит проксується ядром на backendUrl цільового плагіна методом POST. - */ async callPluginApi(pluginId: string, path: string, body?: any): Promise { return this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, { method: 'POST', @@ -156,18 +168,11 @@ export class CoreService { }); } - /** - * Отримати список усіх зареєстрованих плагінів з їхніми events, objectTypes, backendUrl тощо. - * Корисно для discovery — дізнатись, які плагіни доступні та що вони вміють. - */ async discoverPlugins(): Promise { const response = await this.fetchCore('/api/plugins/discovery'); return response.data || response || []; } - /** - * Знайти плагіни, які оголосили певну подію у своєму manifest.json (поле events). - */ async findPluginsByEvent(eventName: string): Promise { const plugins = await this.discoverPlugins(); return plugins.filter((p) => @@ -175,18 +180,6 @@ export class CoreService { ); } - /** - * Надіслати подію конкретному плагіну через RabbitMQ. - * Публікує в system_events з routingKey: plugin... - * Цільовий плагін отримує подію через @EventPattern('plugin..') - * або більш загальний @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 { const routingKey = `plugin.${pluginId}.${pattern}`; const event: SystemEvent = { @@ -211,18 +204,6 @@ export class CoreService { } } - /** - * RPC-виклик до іншого плагіна з очікуванням відповіді. - * Використовує NestJS RabbitMQ RPC (send / @MessagePattern). - * Публікує в system_events exchange (raw amqplib, not ClientProxy). - * Цільовий плагін має обробити запит через @MessagePattern('plugin..'). - * - * @param pluginId — ID цільового плагіна - * @param pattern — назва команди - * @param payload — тіло запиту - * @param timeoutMs — таймаут очікування (за замовчуванням 5000 мс) - * @returns відповідь від цільового плагіна або null по таймауту - */ async requestPlugin( pluginId: string, pattern: string, @@ -374,34 +355,34 @@ export class CoreService { }); } - /** - * Отримати назву БД, наданої ядром (має сенс після виклику setupDatabase) - */ getDbName(): string { return process.env.PLUGIN_DB_NAME || process.env.DB_DATABASE || 'ultimate_crm'; } - /** - * Зареєструвати БД плагіна в ядрі (створює БД p_, якщо ще не існує). - * Викликати ДО створення NestJS app, щоб TypeORM міг підхопити PLUGIN_DB_NAME. - */ 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: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ pluginId: options.pluginId }), }); if (!response.ok) { - throw new Error(`Database setup failed: ${response.statusText}`); + 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.PLUGIN_DB_HOST = data.host; - if (data.port) process.env.PLUGIN_DB_PORT = String(data.port); + 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; diff --git a/src/backend/services/report-capability.service.ts b/src/backend/services/report-capability.service.ts new file mode 100644 index 0000000..a14b472 --- /dev/null +++ b/src/backend/services/report-capability.service.ts @@ -0,0 +1,49 @@ +import { Controller, Inject, Type } from '@nestjs/common'; +import { MessagePattern } from '@nestjs/microservices'; +import { ReportControllerOptions } from '../interfaces/report-capability.types'; + +export function createReportController(options: ReportControllerOptions): Type { + const { pluginId, endpoints, handlerToken, methodPrefix = 'handle' } = options; + + @Controller() + class ReportCapabilityController { + constructor(@Inject(handlerToken) private readonly handler: any) {} + + @MessagePattern(`plugin.${pluginId}.report.capabilities`) + async getCapabilities() { + return { + endpoints: endpoints.map(e => ({ + endpoint: e.endpoint, + label: e.label, + description: e.description, + outputFields: e.outputFields, + filters: e.filters, + })), + }; + } + } + + for (const ep of endpoints) { + const pattern = `plugin.${pluginId}.report.${ep.endpoint}`; + const safeName = ep.endpoint.replace(/[^a-zA-Z0-9_]/g, '_'); + const methodName = `handle_${safeName}`; + const handlerMethod = `${methodPrefix}${ep.endpoint.charAt(0).toUpperCase()}${ep.endpoint.slice(1)}`; + + Object.defineProperty(ReportCapabilityController.prototype, methodName, { + value: async function (this: any, payload: any) { + return this.handler[handlerMethod](payload); + }, + writable: true, + enumerable: false, + configurable: true, + }); + + const descriptor = Object.getOwnPropertyDescriptor( + ReportCapabilityController.prototype, + methodName, + ); + MessagePattern(pattern)(ReportCapabilityController.prototype, methodName, descriptor!); + } + + return ReportCapabilityController; +} diff --git a/src/index.ts b/src/index.ts index 433c0e9..7ba311c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export * from './backend/services/core.service'; export * from './backend/services/core-client.service'; export * from './backend/interfaces/communication'; +export * from './backend/interfaces/report-capability.types'; +export * from './backend/services/report-capability.service'; export * from './backend/filters/exception.filter'; export * from './backend/interceptors/request-context.interceptor'; export * from './backend/interceptors/transform.interceptor';