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",
"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"

View File

@ -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": {

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,
) {}
/**
* Спеціальний метод для виконання запитів до Ядра з автоматичним прокиданням авторизації
*/
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<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)) {
@ -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<T = any>(pluginId: string, path: string, body?: any): Promise<T> {
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<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) =>
@ -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> {
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.<pluginId>.<pattern>').
*
* @param pluginId ID цільового плагіна
* @param pattern назва команди
* @param payload тіло запиту
* @param timeoutMs таймаут очікування (за замовчуванням 5000 мс)
* @returns відповідь від цільового плагіна або null по таймауту
*/
async requestPlugin<T = any>(
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_<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 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`, {
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;

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-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';