feat: add ReportCapabilityProvider factory for report data sources — v1.9.0

This commit is contained in:
Кирилл Осипков 2026-06-01 11:02:25 +03:00
parent f92adbd683
commit 3136f0b5f4
6 changed files with 122 additions and 59 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@erp/plugin-sdk", "name": "@erp/plugin-sdk",
"version": "1.5.0", "version": "1.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@erp/plugin-sdk", "name": "@erp/plugin-sdk",
"version": "1.5.0", "version": "1.9.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"uuid": "^9.0.1" "uuid": "^9.0.1"

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/plugin-sdk", "name": "@erp/plugin-sdk",
"version": "1.7.1", "version": "1.9.0",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"bin": { "bin": {

View File

@ -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;
}

View File

@ -18,9 +18,6 @@ export class CoreService {
@Inject('RABBITMQ_CLIENT') private readonly client: ClientProxy, @Inject('RABBITMQ_CLIENT') private readonly client: ClientProxy,
) {} ) {}
/**
* Спеціальний метод для виконання запитів до Ядра з автоматичним прокиданням авторизації
*/
async fetchCore(endpoint: string, options: RequestInit = {}) { async fetchCore(endpoint: string, options: RequestInit = {}) {
const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001';
const store = requestContext.getStore(); const store = requestContext.getStore();
@ -46,15 +43,13 @@ export class CoreService {
return response.json(); return response.json();
} }
// ... (решта методів register, emit, request залишаються без змін)
async register(): Promise<void> { async register(): Promise<void> {
const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001';
const possiblePaths = [ const possiblePaths = [
path.resolve(process.cwd(), '..', 'manifest.json'), path.resolve(process.cwd(), '..', 'manifest.json'),
path.resolve(process.cwd(), 'manifest.json'), path.resolve(process.cwd(), 'manifest.json'),
]; ];
let manifestPath = ''; let manifestPath = '';
for (const p of possiblePaths) { for (const p of possiblePaths) {
if (fs.existsSync(p)) { if (fs.existsSync(p)) {
@ -65,12 +60,13 @@ export class CoreService {
if (!manifestPath) return; if (!manifestPath) return;
// Dynamically calculate the backendUrl
const port = process.env.PORT || '3002'; const port = process.env.PORT || '3002';
const serviceName = process.env.SERVICE_NAME || 'plugin'; const serviceName = process.env.SERVICE_NAME || 'plugin';
const isDocker = coreUrl.includes('core-backend') || (!coreUrl.includes('localhost') && !coreUrl.includes('127.0.0.1')); 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 backendUrl = process.env.PLUGIN_BACKEND_URL || (isDocker ? `http://${serviceName}:${port}` : `http://localhost:${port}`);
const registrationKey = process.env.REGISTRATION_KEY || '';
let registered = false; let registered = false;
while (!registered) { while (!registered) {
try { try {
@ -96,18 +92,38 @@ export class CoreService {
const response = await fetch(`${coreUrl}/api/plugins/register`, { const response = await fetch(`${coreUrl}/api/plugins/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(registrationKey ? { 'x-registration-key': registrationKey } : {}),
},
body: JSON.stringify(manifest), body: JSON.stringify(manifest),
}); });
if (response.ok) { if (response.ok) {
const resBody = await response.json();
const data = resBody && resBody.data !== undefined ? resBody.data : resBody;
this.logger.log(`Plugin registered successfully!`); 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; registered = true;
this.startHeartbeatLoop(manifest.id, backendUrl); this.startHeartbeatLoop(manifest.id, backendUrl);
} else { } else {
const errText = await response.text();
this.logger.error(`Registration failed (${response.status}): ${errText}`);
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
} catch (error) { } catch (error) {
this.logger.error(`Registration error: ${error}`);
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
} }
@ -126,7 +142,7 @@ export class CoreService {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: pluginId, backendUrl }), body: JSON.stringify({ id: pluginId, backendUrl }),
}); });
if (response.ok) { if (response.ok) {
const resBody = await response.json(); const resBody = await response.json();
const data = resBody && resBody.data !== undefined ? resBody.data : resBody; const data = resBody && resBody.data !== undefined ? resBody.data : resBody;
@ -142,13 +158,9 @@ export class CoreService {
} catch (error: any) { } catch (error: any) {
this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error); this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error);
} }
}, 30 * 1000); // 30 seconds }, 30 * 1000);
} }
/**
* Викликати API іншого плагіна через core RPC-проксі.
* Запит проксується ядром на backendUrl цільового плагіна методом POST.
*/
async callPluginApi<T = any>(pluginId: string, path: string, body?: any): Promise<T> { async callPluginApi<T = any>(pluginId: string, path: string, body?: any): Promise<T> {
return this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, { return this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, {
method: 'POST', method: 'POST',
@ -156,18 +168,11 @@ export class CoreService {
}); });
} }
/**
* Отримати список усіх зареєстрованих плагінів з їхніми events, objectTypes, backendUrl тощо.
* Корисно для discovery дізнатись, які плагіни доступні та що вони вміють.
*/
async discoverPlugins(): Promise<any[]> { async discoverPlugins(): Promise<any[]> {
const response = await this.fetchCore('/api/plugins/discovery'); const response = await this.fetchCore('/api/plugins/discovery');
return response.data || response || []; return response.data || response || [];
} }
/**
* Знайти плагіни, які оголосили певну подію у своєму manifest.json (поле events).
*/
async findPluginsByEvent(eventName: string): Promise<any[]> { async findPluginsByEvent(eventName: string): Promise<any[]> {
const plugins = await this.discoverPlugins(); const plugins = await this.discoverPlugins();
return plugins.filter((p) => return plugins.filter((p) =>
@ -175,18 +180,6 @@ export class CoreService {
); );
} }
/**
* Надіслати подію конкретному плагіну через 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> { async emitToPlugin(pluginId: string, pattern: string, payload: any): Promise<void> {
const routingKey = `plugin.${pluginId}.${pattern}`; const routingKey = `plugin.${pluginId}.${pattern}`;
const event: SystemEvent = { 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.<pluginId>.<pattern>').
*
* @param pluginId ID цільового плагіна
* @param pattern назва команди
* @param payload тіло запиту
* @param timeoutMs таймаут очікування (за замовчуванням 5000 мс)
* @returns відповідь від цільового плагіна або null по таймауту
*/
async requestPlugin<T = any>( async requestPlugin<T = any>(
pluginId: string, pluginId: string,
pattern: string, pattern: string,
@ -374,34 +355,34 @@ export class CoreService {
}); });
} }
/**
* Отримати назву БД, наданої ядром (має сенс після виклику setupDatabase)
*/
getDbName(): string { getDbName(): string {
return process.env.PLUGIN_DB_NAME || process.env.DB_DATABASE || 'ultimate_crm'; 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> { static async setupDatabase(options: { pluginId: string }): Promise<string> {
const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001';
const registrationKey = process.env.REGISTRATION_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (registrationKey) {
headers['x-registration-key'] = registrationKey;
}
const response = await fetch(`${coreUrl}/api/db/setup`, { const response = await fetch(`${coreUrl}/api/db/setup`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ pluginId: options.pluginId }), body: JSON.stringify({ pluginId: options.pluginId }),
}); });
if (!response.ok) { 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 result = await response.json();
const data = result.data || result; const data = result.data || result;
const dbName = data.dbName; const dbName = data.dbName;
if (!dbName) throw new Error('Database name not returned from core'); if (!dbName) throw new Error('Database name not returned from core');
process.env.PLUGIN_DB_NAME = dbName; process.env.PLUGIN_DB_NAME = dbName;
if (data.host) process.env.PLUGIN_DB_HOST = data.host; if (data.host && !process.env.DB_HOST) process.env.PLUGIN_DB_HOST = data.host;
if (data.port) process.env.PLUGIN_DB_PORT = String(data.port); 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.username) process.env.PLUGIN_DB_USER = data.username;
if (data.password) process.env.PLUGIN_DB_PASSWORD = data.password; if (data.password) process.env.PLUGIN_DB_PASSWORD = data.password;
return dbName; return dbName;

View File

@ -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<any> {
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;
}

View File

@ -2,6 +2,8 @@
export * from './backend/services/core.service'; export * from './backend/services/core.service';
export * from './backend/services/core-client.service'; export * from './backend/services/core-client.service';
export * from './backend/interfaces/communication'; 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/filters/exception.filter';
export * from './backend/interceptors/request-context.interceptor'; export * from './backend/interceptors/request-context.interceptor';
export * from './backend/interceptors/transform.interceptor'; export * from './backend/interceptors/transform.interceptor';