Compare commits

...

19 Commits

Author SHA1 Message Date
Kyryll O. e44203c485 fix: emit() publishes to exchange instead of sendToQueue
NestJS v10 RMQ client uses sendToQueue which sends to a specific
queue (core_queue), bypassing the exchange. Messages were never
delivered to other plugins' queues bound to the exchange.

Changed emit() to use amqplib to publish directly to system_events
exchange with {pattern, data} format that NestJS RMQ server expects.
2026-06-19 16:17:47 +03:00
Kyryll O. fb0bb36bdd fix: publish system.notification.send in {pattern, data} format for NestJS RMQ 2026-06-18 16:45:12 +03:00
Kyryll O. 8209f4ac82 fix: sendNotification publishes directly to exchange instead of NestJS RMQ client sendToQueue 2026-06-18 16:32:01 +03:00
Kyryll O. 67f58d6f74 1.11.0 2026-06-12 17:55:18 +03:00
Kyryll O. 3822e91243 chore: bump to v1.10.3 (callPluginApi unwrap response) 2026-06-12 17:54:57 +03:00
Kyryll O. de45ca428b fix: callPluginApi unwrap response (TransformInterceptor wrapping) 2026-06-12 17:53:48 +03:00
Kyryll O. 3451a23e8e feat: add callAsService for service-to-service auth
- Add CoreService.callAsService(pluginId, path, body) method
- Signs requests with HMAC-SHA256 using REGISTRATION_KEY
- Sends X-Service-Auth header for service-to-service authentication
2026-06-04 13:28:50 +03:00
Kyryll O. b4d214d9c3 feat: add HelpModule (HelpController, HelpService) for standardized FAQ/HELP system
HelpModule auto-discovers articles from help/*.md files or articles.json.
Exposes GET /api/help (articles list) and GET /api/help/:id (markdown content).
Supports i18n via {id}.{lang}.md file pattern.
2026-06-03 15:41:48 +03:00
Kyryll O. 4c31519308 chore: bump 1.10.0, publish 2026-06-01 14:31:24 +03:00
Kyryll O. c5f43da97b fix(sdk): requestPlugin skips cross-service 'no matching handler' errors with fallback timer 2026-06-01 14:30:29 +03:00
Кирилл Осипков 89d871e5d6 chore: bump 1.9.2, safe discoverPlugins with error handling 2026-06-01 14:06:44 +03:00
Кирилл Осипков 04cbcb791e fix: convert endpoint kebab-case to camelCase in createReportController handlerMethod — v1.9.1 2026-06-01 11:27:41 +03:00
Кирилл Осипков 3136f0b5f4 feat: add ReportCapabilityProvider factory for report data sources — v1.9.0 2026-06-01 11:02:31 +03:00
Kyryll O f92adbd683 feat: add DatePicker component to UIKit and CLI scaffold 2026-05-29 22:04:11 +03:00
Кирилл Осипков 25fe858c2b feat: sync UIKit types - add Modal, Tabs, Table, SearchableSelect, MultiSelect, Typography.Small — v1.7.1 2026-05-28 12:08:39 +03:00
Кирилл Осипков 4efaa25452 feat: raw amqplib exchange publishing for inter-plugin RPC — v1.7.0 2026-05-28 11:05:49 +03:00
Кирилл Осипков acc8c3178d feat: inter-plugin discovery, targeted events, RPC — v1.4.0 2026-05-28 09:32:27 +03:00
Кирилл Осипков 11e1827031 docs: add inter-plugin communication (RPC + discovery) docs to README 2026-05-28 09:17:15 +03:00
Кирилл Осипков d9c87aef0a feat: add callPluginApi() and discoverPlugins() for inter-plugin HTTP RPC 2026-05-28 09:15:34 +03:00
14 changed files with 975 additions and 70 deletions

279
README.md
View File

@ -19,6 +19,7 @@ SDK для створення плагінів ERP-платформи. Міст
- [Інтерфейси](#інтерфейси) - [Інтерфейси](#інтерфейси)
- [Attribute Access Control (ABAC)](#attribute-access-control-abac) - [Attribute Access Control (ABAC)](#attribute-access-control-abac)
- [Події та RabbitMQ](#події-та-rabbitmq) - [Події та RabbitMQ](#події-та-rabbitmq)
- [Міжплагінна комунікація](#міжплагінна-комунікація)
- [CLI — create-erp-plugin](#cli--create-erp-plugin) - [CLI — create-erp-plugin](#cli--create-erp-plugin)
- [Експорт SDK](#експорт-sdk) - [Експорт SDK](#експорт-sdk)
@ -64,24 +65,32 @@ npx create-erp-plugin my-plugin --port 3007
``` ```
┌───────────────────┐ HTTP/REST ┌──────────────┐ ┌───────────────────┐ HTTP/REST ┌──────────────┐
│ Plugin (BE) │ ◄──────────────────► │ Core (BE) │ │ Plugin A (BE) │ ◄──────────────────► │ Core (BE) │
│ CoreService │ RabbitMQ │ Port 3001 │ │ CoreService │ RabbitMQ │ Port 3001 │
│ AttributeAccess │ ◄──────────────────► │ │ │ AttributeAccess │ ◄──────────────────► │ │
└────────┬──────────┘ └──────────────┘ └────────┬──────────┘ └──────────┬───┘
│ ┌───────────────────────▲ │
Module Federation │ Plugin→Plugin│ HTTP RPC Proxy │ │
│ v1.3.0 │ │ │
┌────────▼──────────┐ │ ┌──────┴───────┐ │ │
│ Plugin (FE) │ │ │ Plugin B (BE)│ │ │
│ React + Vite │ │ │ callPlugin │───────────────┘ │
│ host/UIKit │ │ └──────────────┘ │
└───────────────────┘ │ │
Module Federation Module Fed.
│ │
┌────────▼──────────┐ ┌──────────▼───┐
│ Plugin A (FE) │ │ Plugin B(FE)│
│ React + Vite │ │ React + Vite│
│ host/UIKit │ │ host/UIKit │
└───────────────────┘ └──────────────┘
``` ```
**Комунікація:** **Комунікація:**
1. **HTTP (REST)**`CoreService.fetchCore()` для запитів до ядра 1. **HTTP (REST)**`CoreService.fetchCore()` для запитів до ядра
2. **RabbitMQ (асинхронно)**`CoreService.emit()` для подій, `@EventPattern()` для отримання 2. **RabbitMQ (асинхронно)**`CoreService.emit()` для подій, `@EventPattern()` для отримання
3. **Module Federation** — фронтенд плагіна монтується як remote module всередині host-застосунку 3. **Plugin→Plugin RPC** (v1.3.0+) — `CoreService.callPluginApi()` для виклику API іншого плагіна через core-proxy
4. **Module Federation** — фронтенд плагіна монтується як remote module всередині host-застосунку
--- ---
@ -152,6 +161,99 @@ await coreService.sendNotification({
}); });
``` ```
#### `callPluginApi<T>(pluginId, path, body?)`
Викликати API іншого плагіна через core RPC-проксі. Запит проксується ядром на `backendUrl` цільового плагіна методом POST. JWT-токен автоматично прокидyється.
```typescript
// Викликати ендпоінт плагіна notifications
const result = await coreService.callPluginApi('notifications', 'stats', { period: 'week' });
// Викликати з вкладеним шляхом
const user = await coreService.callPluginApi('user-management', 'users/42');
```
#### `findPluginsByEvent(eventName)`
Знайти плагіни, які оголосили певну подію у своєму `manifest.json`.
```typescript
const listeners = await coreService.findPluginsByEvent('core.user.created');
// listeners = [ { id: 'audit-logs', name: 'Audit Logs', ... } ]
```
#### `emitToPlugin(pluginId, pattern, payload)`
Надіслати подію конкретному плагіну через RabbitMQ. Публікує в `system_events` з routing key `plugin.<pluginId>.<pattern>`. Цільовий плагін отримує подію через `@EventPattern()`.
```typescript
// Надіслати подію тільки плагіну notifications
await coreService.emitToPlugin('notifications', 'send', {
userId: 1,
title: 'New deal',
message: 'Deal #42 created',
});
```
Цільовий плагін обробляє через стандартний `@EventPattern`:
```typescript
@EventPattern('plugin.notifications.send')
async handle(@Payload() data: any) {
const event = data.data || data;
// event.payload = { userId: 1, title: 'New deal', ... }
}
```
#### `requestPlugin<T>(pluginId, pattern, payload, timeoutMs?)`
RPC-виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS RabbitMQ RPC (`send` / `@MessagePattern`).
```typescript
const stats = await coreService.requestPlugin('notifications', 'getStats', {
period: 'week',
});
// stats = { sent: 42, failed: 1 }
```
Цільовий плагін обробляє через `@MessagePattern`:
```typescript
@MessagePattern('plugin.notifications.getStats')
async getStats(@Payload() data: { period: string }) {
// логіка підрахунку...
return { sent: 42, failed: 1 };
}
```
Параметри:
- `pluginId` — ID цільового плагіна
- `pattern` — назва команди (без префікса `plugin.<id>.`, він додається автоматично)
- `payload` — тіло запиту
- `timeoutMs` — таймаут (за замовчуванням 5000 мс). Повертає `null` при перевищенні.
#### `discoverPlugins()`
Отримати список усіх зареєстрованих плагінів з їхніми можливостями. Повертає масив маніфестів з полями: `id`, `name`, `label`, `icon`, `routePath`, `backendUrl`, `events`, `objectTypes`, `permissions`, `roles`, `database`, `menu`, `headerActions`.
```typescript
const plugins = await coreService.discoverPlugins();
// Знайти плагін за id
const notifPlugin = plugins.find(p => p.id === 'notifications');
console.log(notifPlugin.backendUrl); // http://notifications:3008
// Знайти всі плагіни, що оголосили певну подію
const listeners = plugins.filter(p =>
p.events?.some(e => e.name === 'core.user.created')
);
// Знайти плагіни, що працюють з певним типом об'єктів
const dealPlugins = plugins.filter(p =>
p.objectTypes?.includes('deal')
);
```
#### `getDbName()` #### `getDbName()`
Отримати назву БД (після `setupDatabase`). Отримати назву БД (після `setupDatabase`).
@ -301,12 +403,22 @@ await coreClient.notifyCore('myplugin.event', { data: 'value' });
```typescript ```typescript
import { getRabbitMQOptions } from '@erp/plugin-sdk'; import { getRabbitMQOptions } from '@erp/plugin-sdk';
// Звичайне підключення — отримує всі події
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue')); app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
// З підтримкою targeted events — також байндить plugin.<id>.*
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue', undefined, manifest.id));
``` ```
Параметри:
- `queueName` — назва черги
- `url?` — RabbitMQ URL (за замовчуванням з `RABBITMQ_URL` або `amqp://localhost:5672`)
- `pluginId?` — (v1.4.0+) ID плагіна для додаткового binding `plugin.<pluginId>.*` (targeted events)
Конфігурація: Конфігурація:
- Exchange: `system_events` (topic) - Exchange: `system_events` (topic)
- Routing key: `#` (отримує всі події) - Routing keys: `#` (всі події) + `plugin.<id>.*` (якщо передано `pluginId`)
- Queue: вказана назва (durable) - Queue: вказана назва (durable)
- Повідомлення: persistent - Повідомлення: persistent
@ -552,6 +664,147 @@ async handle(@Payload() data: any) {
--- ---
## Міжплагінна комунікація
До версії 1.3.0 плагіни могли спілкуватись тільки через ядро (HTTP до core + RabbitMQ broadcast). Починаючи з v1.3.0 додано підтримку прямого плагін→плагін зв'язку через HTTP RPC проксі.
### Архітектура
```
Plugin A Core Backend Plugin B
| | |
|-- discoverPlugins() ----------->| |
|<-- [{ id:'notifications', ... }]| |
| | |
|-- callPluginApi('notifications', | |
| 'stats', { period:'week' })-->| |
| |-- POST /stats -------------->|
| |<-- { sent: 42 } ------------|
|<-- { sent: 42 } ----------------| |
```
Всі запити проходять через Core, який:
1. **Перевіряє JWT** (як і будь-який захищений ендпоінт)
2. **Визначає `backendUrl`** цільового плагіна з реєстру
3. **Проксує POST-запит** з автентифікацією та тілом
### Усі способи комунікації
| Метод | Тип | Канал | Адресат |
|-------|-----|-------|---------|
| `callPluginApi()` | Sync HTTP RPC | HTTP через core-proxy | Конкретний плагін |
| `emitToPlugin()` | Async event | RabbitMQ `plugin.<id>.<pattern>` | Конкретний плагін |
| `requestPlugin()` | Sync RPC | RabbitMQ `send/@MessagePattern` | Конкретний плагін |
| `emit()` | Async event | RabbitMQ broadcast | Всі плагіни |
### 1. Discovery
```typescript
// Всі зареєстровані плагіни
const plugins = await coreService.discoverPlugins();
// Знайти плагіни за подією
const listeners = await coreService.findPluginsByEvent('core.user.created');
```
Кожен плагін має:
- `id`, `name`, `label`, `backendUrl` — адреса для HTTP RPC
- `events: { name, description }[]` — які події оголошує
- `objectTypes: string[]` — з якими об'єктами працює
- `permissions`, `roles` — права доступу
### 2. Targeted Events (RabbitMQ)
Надіслати подію конкретному плагіну через RabbitMQ. Подія публікується в `system_events` з routing key `plugin.<pluginId>.<pattern>`. Цільовий плагін має додатковий binding `plugin.<id>.*` (якщо передано `pluginId` у `getRabbitMQOptions`).
```typescript
// Відправник
await coreService.emitToPlugin('notifications', 'send', {
userId: 1,
title: 'New deal',
message: 'Deal #42 created',
});
// Отримувач (notifications plugin)
@EventPattern('plugin.notifications.send')
async handle(@Payload() data: any) {
const event = data.data || data;
console.log(event.payload.title); // 'New deal'
}
```
**Підключення отримувача** (в `main.ts`):
```typescript
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
app.connectMicroservice(
getRabbitMQOptions('plugin_notifications_queue', undefined, manifest.id)
);
// Черга байндиться з routing keys: # (всі події) + plugin.notifications.* (targeted)
```
### 3. RPC Request/Response (RabbitMQ)
Синхронний виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS `send()` / `@MessagePattern()`.
```typescript
// Відправник
const stats = await coreService.requestPlugin('notifications', 'getStats', {
period: 'week',
});
console.log(stats); // { sent: 42, failed: 1 }
// Отримувач (notifications plugin)
@MessagePattern('plugin.notifications.getStats')
async getStats(@Payload() data: { period: string }) {
const stats = await this.calcStats(data.period);
return stats; // повертається відправнику
}
```
Параметри `requestPlugin`:
- `pluginId` — ID цільового плагіна
- `pattern` — назва команди (без префікса)
- `payload` — тіло запиту
- `timeoutMs` — таймаут у мс (за замовчуванням 5000). `null` при перевищенні.
### 4. HTTP RPC через core-proxy
```typescript
const stats = await coreService.callPluginApi('notifications', 'stats');
const user = await coreService.callPluginApi('user-management', 'users/42');
const result = await coreService.callPluginApi('global-store', 'store/set', {
key: 'my-key',
value: { foo: 'bar' },
});
```
**Обмеження:**
- Тільки POST-метод
- Потребує JWT-токена (автоматично прокидyється з контексту)
- Якщо плагін не зареєстрований або без `backendUrl` — 404
### Типовий сценарій
```typescript
async function notifyAndLog() {
// 1. Discovery
const [notifPlugin] = await this.coreService.findPluginsByEvent('notification.send');
// 2. Targeted event
if (notifPlugin) {
await this.coreService.emitToPlugin('notifications', 'send', {
userId: 1,
title: 'Event occurred',
message: 'Plugin A triggered a notification',
});
}
// 3. RPC запит до audit-logs
const status = await this.coreService.requestPlugin('audit-logs', 'dailyStats', {});
}
---
## CLI — create-erp-plugin ## CLI — create-erp-plugin
Генерація нового плагіна: Генерація нового плагіна:
@ -612,7 +865,7 @@ my-plugin/
``` ```
@erp/plugin-sdk @erp/plugin-sdk
├── CoreService # Головний сервіс (fetchCore, emit, register, ...) ├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, findPluginsByEvent, emitToPlugin, requestPlugin, ...)
├── CoreClientService # RabbitMQ RPC клієнт ├── CoreClientService # RabbitMQ RPC клієнт
├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу ├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу
├── getRabbitMQOptions() # NestJS Microservice options ├── getRabbitMQOptions() # NestJS Microservice options

113
package-lock.json generated
View File

@ -1,33 +1,45 @@
{ {
"name": "erp-plugin-sdk", "name": "@erp/plugin-sdk",
"version": "1.0.0", "version": "1.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erp-plugin-sdk", "name": "@erp/plugin-sdk",
"version": "1.0.0", "version": "1.11.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"uuid": "^9.0.1"
},
"bin": {
"create-erp-plugin": "dist/cli/index.js"
},
"devDependencies": {
"@nestjs/common": "^11.1.23", "@nestjs/common": "^11.1.23",
"@nestjs/core": "^11.1.23", "@nestjs/core": "^11.1.23",
"@nestjs/microservices": "^11.1.23", "@nestjs/microservices": "^11.1.23",
"@types/amqplib": "^0.10.8",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"typescript": "^6.0.3", "typescript": "^6.0.3"
"uuid": "^9.0.1"
}, },
"bin": { "peerDependencies": {
"create-erp-plugin": "dist/cli/index.js" "@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"@nestjs/microservices": "^10.0.0 || ^11.0.0",
"amqplib": "^0.10.3",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.8.2"
} }
}, },
"node_modules/@borewit/text-codec": { "node_modules/@borewit/text-codec": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@ -38,6 +50,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
"integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -47,6 +60,7 @@
"version": "11.1.23", "version": "11.1.23",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.23.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.23.tgz",
"integrity": "sha512-qKgEqwQXHIVu8TwiISmgbTrGHAFBsseP86KNolBZwAiHQryinJ5FPiDpp0ZJBBryY+WEMnsqaCa4TSxVuQEWug==", "integrity": "sha512-qKgEqwQXHIVu8TwiISmgbTrGHAFBsseP86KNolBZwAiHQryinJ5FPiDpp0ZJBBryY+WEMnsqaCa4TSxVuQEWug==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"file-type": "21.3.4", "file-type": "21.3.4",
@ -78,6 +92,7 @@
"version": "11.1.23", "version": "11.1.23",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.23.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.23.tgz",
"integrity": "sha512-Yd+mVFUilw4A6PzV7tyfiW+zrG2wmRXnFZVmNQA+fl1N0k6km4bhhNboxjLu//dzl+XiZI5AsOHHOTegzvOgNQ==", "integrity": "sha512-Yd+mVFUilw4A6PzV7tyfiW+zrG2wmRXnFZVmNQA+fl1N0k6km4bhhNboxjLu//dzl+XiZI5AsOHHOTegzvOgNQ==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -119,6 +134,7 @@
"version": "11.1.23", "version": "11.1.23",
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.23.tgz", "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.23.tgz",
"integrity": "sha512-iVPp3u274Xx6XAEbKPWDCzUHP6wlt7L7RXUIyIpf3caJ3ldFh/dG3uyHva1lgjHG+ukz+XCXafl1cx352TPW0w==", "integrity": "sha512-iVPp3u274Xx6XAEbKPWDCzUHP6wlt7L7RXUIyIpf3caJ3ldFh/dG3uyHva1lgjHG+ukz+XCXafl1cx352TPW0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"iterare": "1.2.1", "iterare": "1.2.1",
@ -177,6 +193,7 @@
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
"integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"consola": "^3.2.3" "consola": "^3.2.3"
@ -193,6 +210,7 @@
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.4.3", "debug": "^4.4.3",
@ -210,12 +228,24 @@
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/amqplib": {
"version": "0.10.8",
"resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.8.tgz",
"integrity": "sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.1", "version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": ">=7.24.0 <7.24.7" "undici-types": ">=7.24.0 <7.24.7"
@ -225,6 +255,7 @@
"version": "19.2.15", "version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -234,12 +265,35 @@
"version": "9.0.8", "version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/amqplib": {
"version": "0.10.9",
"resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz",
"integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==",
"license": "MIT",
"peer": true,
"dependencies": {
"buffer-more-ints": "~1.0.0",
"url-parse": "~1.5.10"
},
"engines": {
"node": ">=10"
}
},
"node_modules/buffer-more-ints": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz",
"integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==",
"license": "MIT",
"peer": true
},
"node_modules/consola": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.18.0 || >=16.10.0" "node": "^14.18.0 || >=16.10.0"
@ -249,12 +303,14 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -272,12 +328,14 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/file-type": { "node_modules/file-type": {
"version": "21.3.4", "version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tokenizer/inflate": "^0.4.1", "@tokenizer/inflate": "^0.4.1",
@ -296,6 +354,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -316,6 +375,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz",
"integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -325,6 +385,7 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz",
"integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -344,28 +405,46 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT",
"peer": true
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT",
"peer": true
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
@ -375,6 +454,7 @@
"version": "10.3.5", "version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tokenizer/token": "^0.3.0" "@tokenizer/token": "^0.3.0"
@ -391,6 +471,7 @@
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@borewit/text-codec": "^0.2.1", "@borewit/text-codec": "^0.2.1",
@ -409,12 +490,14 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -428,6 +511,7 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",
"integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lukeed/csprng": "^1.0.0" "@lukeed/csprng": "^1.0.0"
@ -440,6 +524,7 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -452,8 +537,20 @@
"version": "7.24.6", "version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/plugin-sdk", "name": "@erp/plugin-sdk",
"version": "1.2.4", "version": "1.11.3",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"bin": { "bin": {
@ -19,7 +19,10 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "ERP Plugin SDK — build micro-frontend/backend plugins for the ERP platform", "description": "ERP Plugin SDK — build micro-frontend/backend plugins for the ERP platform",
"files": ["dist/", "README.md"], "files": [
"dist/",
"README.md"
],
"publishConfig": { "publishConfig": {
"registry": "https://gitea.ss.3w.com.ua/api/packages/erp/npm/" "registry": "https://gitea.ss.3w.com.ua/api/packages/erp/npm/"
}, },
@ -30,6 +33,7 @@
"@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0",
"@nestjs/microservices": "^10.0.0 || ^11.0.0", "@nestjs/microservices": "^10.0.0 || ^11.0.0",
"amqplib": "^0.10.3",
"reflect-metadata": "^0.1.12 || ^0.2.0", "reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
}, },
@ -37,6 +41,7 @@
"@nestjs/common": "^11.1.23", "@nestjs/common": "^11.1.23",
"@nestjs/core": "^11.1.23", "@nestjs/core": "^11.1.23",
"@nestjs/microservices": "^11.1.23", "@nestjs/microservices": "^11.1.23",
"@types/amqplib": "^0.10.8",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",

View File

@ -1,6 +1,11 @@
import { Transport, MicroserviceOptions } from '@nestjs/microservices'; import { Transport, MicroserviceOptions, RmqOptions } from '@nestjs/microservices';
import * as amqp from 'amqplib';
export const getRabbitMQOptions = (queueName: string, url?: string): MicroserviceOptions => { export const getRabbitMQOptions = (queueName: string, url?: string, pluginId?: string): MicroserviceOptions => {
const routingKeys: string[] = ['#'];
if (pluginId) {
routingKeys.push(`plugin.${pluginId}.*`);
}
return { return {
transport: Transport.RMQ, transport: Transport.RMQ,
options: { options: {
@ -8,12 +13,13 @@ export const getRabbitMQOptions = (queueName: string, url?: string): Microservic
queue: queueName, queue: queueName,
exchange: 'system_events', exchange: 'system_events',
exchangeType: 'topic', exchangeType: 'topic',
routingKey: '#', routingKey: routingKeys,
queueOptions: { queueOptions: {
durable: true, // Черга не зникає при перезавантаженні RabbitMQ durable: true,
}, },
persistent: true, // Повідомлення зберігаються на диску persistent: true,
}, noAck: true,
} as RmqOptions['options'] & { routingKey: string[] },
}; };
}; };
@ -31,3 +37,29 @@ export const getRabbitMQClientOptions = (url?: string) => {
}, },
}; };
}; };
/**
* Bind a queue to the system_events exchange with the given routing keys.
* NestJS 10's ServerRMQ does not automatically bind queues to exchanges,
* so this must be called after app.startAllMicroservices().
* NestJS 11+ handles this automatically via the exchange/routingKey options.
*/
export async function setupQueueBindings(
queueName: string,
routingKeys: string[],
url?: string,
): Promise<void> {
const rmqUrl = url || process.env.RABBITMQ_URL || 'amqp://localhost:5672';
const conn = await amqp.connect(rmqUrl);
try {
const channel = await conn.createChannel();
await channel.assertExchange('system_events', 'topic', { durable: true });
await channel.assertQueue(queueName, { durable: true });
for (const key of routingKeys) {
await channel.bindQueue(queueName, 'system_events', key);
}
await channel.close();
} finally {
await conn.close().catch(() => {});
}
}

View File

@ -0,0 +1,25 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { HelpService, HelpArticle } from './help.service';
@Controller('api/help')
export class HelpController {
constructor(private readonly helpService: HelpService) {}
@Get()
getArticles(): { articles: HelpArticle[] } {
return { articles: this.helpService.getArticles() };
}
@Get(':id')
getArticle(
@Param('id') id: string,
@Query('lang') lang?: string,
): string {
const language = lang || 'en';
const content = this.helpService.getArticleContent(id, language);
if (content === null) {
return `# Not Found\nHelp article "${id}" not found for language "${language}".`;
}
return content;
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HelpController } from './help.controller';
import { HelpService } from './help.service';
@Module({
controllers: [HelpController],
providers: [HelpService],
exports: [HelpService],
})
export class HelpModule {}

View File

@ -0,0 +1,130 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
export interface HelpArticle {
id: string;
title: string | Record<string, string>;
description?: string | Record<string, string>;
}
export interface HelpArticlesJson {
articles: HelpArticle[];
}
@Injectable()
export class HelpService {
private readonly logger = new Logger(HelpService.name);
private readonly helpDir: string;
private cachedArticles: HelpArticle[] | null = null;
constructor() {
this.helpDir = path.resolve(process.cwd(), 'help');
}
getArticles(): HelpArticle[] {
if (this.cachedArticles) return this.cachedArticles;
if (!fs.existsSync(this.helpDir)) {
this.logger.warn(`Help directory not found: ${this.helpDir}`);
this.cachedArticles = [];
return this.cachedArticles;
}
// Try articles.json first
const jsonPath = path.join(this.helpDir, 'articles.json');
if (fs.existsSync(jsonPath)) {
try {
const raw = fs.readFileSync(jsonPath, 'utf-8');
const parsed: HelpArticlesJson = JSON.parse(raw);
if (parsed.articles && Array.isArray(parsed.articles)) {
this.cachedArticles = parsed.articles;
return this.cachedArticles;
}
} catch (e: any) {
this.logger.error(`Failed to parse articles.json: ${e.message}`);
}
}
// Fallback: auto-discover from .md files
return this.discoverArticlesFromFiles();
}
getArticleContent(id: string, lang: string): string | null {
if (!fs.existsSync(this.helpDir)) return null;
// Try lang-specific file first: help/{id}.{lang}.md
const langPath = path.join(this.helpDir, `${id}.${lang}.md`);
if (fs.existsSync(langPath)) {
return fs.readFileSync(langPath, 'utf-8');
}
// Fallback to .en.md
const enPath = path.join(this.helpDir, `${id}.en.md`);
if (fs.existsSync(enPath)) {
return fs.readFileSync(enPath, 'utf-8');
}
// Fallback to plain {id}.md
const plainPath = path.join(this.helpDir, `${id}.md`);
if (fs.existsSync(plainPath)) {
return fs.readFileSync(plainPath, 'utf-8');
}
// Fallback to any matching file
try {
const files = fs.readdirSync(this.helpDir);
const match = files.find(f => f.startsWith(`${id}.`) && f.endsWith('.md'));
if (match) {
return fs.readFileSync(path.join(this.helpDir, match), 'utf-8');
}
} catch (e) {}
return null;
}
clearCache(): void {
this.cachedArticles = null;
}
private discoverArticlesFromFiles(): HelpArticle[] {
try {
const files = fs.readdirSync(this.helpDir).filter(f => f.endsWith('.md'));
const seen = new Set<string>();
const articles: HelpArticle[] = [];
for (const file of files) {
// Pattern: {id}.{lang}.md or {id}.md
const baseName = file.replace(/\.md$/, '');
const parts = baseName.split('.');
let id: string;
if (parts.length >= 2 && parts[parts.length - 1].length === 2) {
// Has language suffix: getting-started.en.md
id = parts.slice(0, -1).join('.');
} else {
// Plain name: getting-started.md
id = baseName;
}
if (seen.has(id)) continue;
seen.add(id);
// Extract title from first h1
const content = fs.readFileSync(path.join(this.helpDir, file), 'utf-8');
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : id;
const descMatch = content.match(/^>\s+(.+)$/m);
const description = descMatch ? descMatch[1].trim() : undefined;
articles.push({ id, title, ...(description ? { description } : {}) });
}
return articles;
} catch (e: any) {
this.logger.error(`Failed to discover help articles: ${e.message}`);
return [];
}
}
}

View File

@ -0,0 +1,3 @@
export { HelpModule } from './help.module';
export { HelpService, HelpArticle } from './help.service';
export { HelpController } from './help.controller';

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

@ -1,10 +1,12 @@
import { Injectable, Inject, Logger } from '@nestjs/common'; import { Injectable, Inject, Logger } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices'; import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom, timeout as rxTimeout } from 'rxjs';
import { SystemEvent } from '../interfaces/communication'; import { SystemEvent } from '../interfaces/communication';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as amqp from 'amqplib';
import { requestContext } from '../interceptors/request-context.interceptor'; import { requestContext } from '../interceptors/request-context.interceptor';
@Injectable() @Injectable()
@ -17,14 +19,10 @@ 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();
const authHeader = store?.get('authorization'); const authHeader = store?.get('authorization');
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(authHeader ? { 'Authorization': authHeader } : {}), ...(authHeader ? { 'Authorization': authHeader } : {}),
@ -45,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)) {
@ -64,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 {
@ -95,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));
} }
} }
@ -125,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;
@ -141,7 +158,169 @@ 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);
}
async callPluginApi<T = any>(pluginId: string, path: string, body?: any): Promise<T> {
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<T = any>(pluginId: string, path: string, body?: any): Promise<T> {
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<any[]> {
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<any[]> {
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<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}`);
}
}
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}`);
if (errorFallback) clearTimeout(errorFallback);
resolve(null);
}, timeoutMs);
let errorFallback: ReturnType<typeof setTimeout> | 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<T>(pattern: string, payload: T): Promise<void> { async emit<T>(pattern: string, payload: T): Promise<void> {
@ -152,9 +331,19 @@ export class CoreService {
source: this.serviceName, source: this.serviceName,
timestamp: Date.now(), timestamp: Date.now(),
}; };
this.client.emit(pattern, event).subscribe({ try {
error: (err) => this.logger.error(`Failed to emit event ${pattern}:`, err), const conn = await amqp.connect(this.getRMQUrl());
}); const ch = await conn.createChannel();
await ch.assertExchange('system_events', 'topic', { durable: true });
ch.publish('system_events', pattern, Buffer.from(JSON.stringify({
pattern,
data: event,
})));
await ch.close();
await conn.close();
} catch (err: any) {
this.logger.error(`Failed to emit event ${pattern}: ${err.message}`);
}
} }
async auditLog(action: string, resource: string, details?: any): Promise<void> { async auditLog(action: string, resource: string, details?: any): Promise<void> {
@ -227,44 +416,56 @@ export class CoreService {
} }
async sendNotification(payload: { userId: number | null; title: string; message: string; icon?: string; image?: string; details?: any }): Promise<void> { async sendNotification(payload: { userId: number | null; title: string; message: string; icon?: string; image?: string; details?: any }): Promise<void> {
await this.emit('system.notification.send', { try {
userId: payload.userId, const conn = await amqp.connect(this.getRMQUrl());
title: payload.title, const ch = await conn.createChannel();
message: payload.message, await ch.assertExchange('system_events', 'topic', { durable: true });
icon: payload.icon, ch.publish('system_events', 'system.notification.send', Buffer.from(JSON.stringify({
image: payload.image, pattern: 'system.notification.send',
details: payload.details, data: {
}); userId: payload.userId,
title: payload.title,
message: payload.message,
icon: payload.icon,
image: payload.image,
details: payload.details,
},
})));
await ch.close();
await conn.close();
} catch (err: any) {
this.logger.error(`[sendNotification] Failed: ${err.message}`);
}
} }
/**
* Отримати назву БД, наданої ядром (має сенс після виклику 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,51 @@
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
.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
.replace(/^([a-z])/, (_, c) => c.toUpperCase())}`;
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

@ -565,16 +565,18 @@ export default defineConfig({
}, },
{ {
relativePath: 'frontend/src/App.tsx', relativePath: 'frontend/src/App.tsx',
content: `import React from 'react'; content: `import React, { useState } from 'react';
import { Puzzle } from 'lucide-react'; import { Puzzle, Calendar } from 'lucide-react';
// @ts-ignore // @ts-ignore
import { Card, Typography } from 'host/UIKit'; import { Card, Typography, DatePicker } from 'host/UIKit';
interface AppProps { interface AppProps {
language?: string; language?: string;
} }
const App: React.FC<AppProps> = ({ language = 'en' }) => { const App: React.FC<AppProps> = ({ language = 'en' }) => {
const [dateValue, setDateValue] = useState<any>(undefined);
return ( return (
<Card className="plugin-card" title={language === 'uk' ? '${label}' : '${label}'}> <Card className="plugin-card" title={language === 'uk' ? '${label}' : '${label}'}>
<div style={{ textAlign: 'center', padding: '40px 0' }}> <div style={{ textAlign: 'center', padding: '40px 0' }}>
@ -585,6 +587,16 @@ const App: React.FC<AppProps> = ({ language = 'en' }) => {
? 'Плагін успішно створено та підключено до CRM.' ? 'Плагін успішно створено та підключено до CRM.'
: 'Plugin successfully created and connected to CRM.'} : 'Plugin successfully created and connected to CRM.'}
</Typography.P> </Typography.P>
<div style={{ maxWidth: '400px', margin: '30px auto' }}>
<DatePicker
label={language === 'uk' ? 'Діапазон дат' : 'Date Range'}
mode="range"
value={dateValue}
onChange={setDateValue}
language={language}
icon={<Calendar size={18} />}
/>
</div>
</div> </div>
</Card> </Card>
); );

View File

@ -23,9 +23,61 @@ declare module 'host/UIKit' {
export const Card: React.FC<CardProps>; export const Card: React.FC<CardProps>;
export const Typography: { export const Typography: {
H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>>; H1: React.FC<{ children: React.ReactNode; className?: string }>;
H2: React.FC<React.HTMLAttributes<HTMLHeadingElement>>; H2: React.FC<{ children: React.ReactNode; className?: string }>;
H3: React.FC<React.HTMLAttributes<HTMLHeadingElement>>; H3: React.FC<{ children: React.ReactNode; className?: string }>;
P: React.FC<React.HTMLAttributes<HTMLParagraphElement>>; P: React.FC<{ children: React.ReactNode; className?: string }>;
Small: React.FC<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }>;
}; };
export const Modal: React.FC<{
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
width?: string;
}>;
export const Tabs: React.FC<{
tabs: { id: string; label: React.ReactNode; icon?: React.ReactNode }[];
activeTab: string;
onChange: (id: string) => void;
className?: string;
}>;
export const Table: React.FC<{
headers: React.ReactNode[];
children: React.ReactNode;
className?: string;
}>;
export const SearchableSelect: React.FC<{
options: { value: string | number; label: string }[];
value: string | number;
onChange: (value: any) => void;
placeholder?: string;
style?: React.CSSProperties;
language?: string;
}>;
export const MultiSelect: React.FC<{
options: { value: string | number; label: string }[];
values: (string | number)[];
onChange: (values: (string | number)[]) => void;
placeholder?: string;
style?: React.CSSProperties;
language?: string;
}>;
export interface DatePickerProps {
label?: string;
mode?: 'single' | 'range';
value?: string | { from?: string; to?: string };
onChange?: (value: any) => void;
placeholder?: string;
language?: string;
error?: string;
icon?: React.ReactNode;
}
export const DatePicker: React.FC<DatePickerProps>;
} }

View File

@ -2,8 +2,11 @@
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';
export * from './backend/config/rabbitmq.config'; export * from './backend/config/rabbitmq.config';
export * from './backend/services/attribute-access.service'; export * from './backend/services/attribute-access.service';
export { HelpModule, HelpService } from './backend/help';